[eric] onboarding revamp — agentic cursor walks users through 8 setup steps, WIP fixes: Gemini schema scrub, OpenAI GPT-5 routing, websearch cascade, gpt-5 is still a bit flaky...

This commit is contained in:
ciregenz
2026-05-09 01:40:37 -07:00
parent c539bf8115
commit 0c841bdad4
60 changed files with 5935 additions and 1861 deletions
+51 -7
View File
@@ -1681,11 +1681,23 @@ class AgentManager:
# MCP to register so WebSearch always cascades through our own
# /api/web/search (Gemini → OpenAI → DuckDuckGo).
_is_custom_session = _api_type_for_session == "custom"
# Only consider the user's own Anthropic API key sufficient
# if the conversation primary IS Claude. Pre-fix: any user
# with an Anthropic key set OR on OpenSwarm Pro skipped the
# openswarm-web MCP registration and the CLI's built-in
# WebSearch routed to Anthropic Haiku — which on a Codex
# /Gemini session drained the Pro pool's Haiku quota for
# WebSearch calls, even though the conversation primary
# (Codex/Gemini) supports native search via its own credits.
# Post-fix: non-Claude primaries always register openswarm-web,
# which cascades Gemini-native → OpenAI-native → subscriptions
# → DDG, only falling to Anthropic if everything else missing.
_has_anthropic_path = (
not _is_custom_session
and _primary_is_claude
and (
bool(getattr(global_settings, "anthropic_api_key", None))
or (_9r_has_anthropic and _primary_is_claude)
or _9r_has_anthropic
)
)
@@ -1859,14 +1871,27 @@ class AgentManager:
}
logger.info(f"[MCP-DEBUG] Using direct Anthropic API key (route=api) for {session.model}")
elif _is_pinned_api_route and _api_route_provider == "openai" and getattr(global_settings, "openai_api_key", None):
# CLI doesn't speak OpenAI; relay through 9Router's translator.
# Goes through 9Router's Anthropic→OpenAI translator like
# other own-key routes — but we point OPENAI_BASE_URL at a
# tiny local pass-through (/api/openai-passthrough/v1) that
# renames max_tokens → max_completion_tokens before relaying
# to api.openai.com. OpenAI's GPT-5 family rejects max_tokens
# with HTTP 400, and 9Router 0.3.60 doesn't know about
# max_completion_tokens yet (its CLI<->OpenAI translator
# emits the legacy field). The pin on 0.3.60 is intentional
# (newer 9Router versions regress WebSearch — see
# nine_router.py comment) so we patch the boundary instead
# of bumping. Pre-fix: every gpt-5.* / gpt-5.* own-key
# session 400'd silently.
from backend.auth import get_auth_token as _get_auth_token_o
_passthrough_url = f"http://127.0.0.1:{os.environ.get('OPENSWARM_PORT', '8324')}/api/openai-passthrough/v1"
options_kwargs["env"] = {
"OPENAI_API_KEY": global_settings.openai_api_key,
"OPENAI_BASE_URL": "https://api.openai.com/v1",
"ANTHROPIC_API_KEY": "9router",
"OPENAI_BASE_URL": _passthrough_url,
"ANTHROPIC_API_KEY": _get_auth_token_o() or "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
logger.info(f"[MCP-DEBUG] Using direct OpenAI API key (route=api) for {session.model}")
logger.info(f"[MCP-DEBUG] Using direct OpenAI API key (route=api) for {session.model} via openai-passthrough")
elif _is_pinned_api_route and _api_route_provider == "custom":
# User-configured OpenAI-compatible endpoint (Ollama Cloud,
# Together, local Ollama, etc.). Routes through 9Router's
@@ -3997,11 +4022,30 @@ class AgentManager:
"""Submit the session state to the cloud on close. The cloud
consumes the dump however it sees fit; the desktop just hands off
a snapshot. Skipped for mock sessions so dev runs don't post to
the real backend."""
the real backend.
Synthesizes a `closed_at` timestamp on the dump if the session
doesn't have one. Two paths previously sent close-events without
a timestamp and made the cloud unable to compute duration_ms
(which surfaced as duration_ms=null on 90% of session.ended events
— browser-agent and shutdown paths in particular):
1. browser_agent.py calls this without setting closed_at.
2. shutdown_all_sessions() clears closed_at to None for the
on-disk restore mechanism, then syncs.
Fix is here at the bottleneck rather than at every caller so we
can't miss a future call site. The on-disk session JSON keeps its
original (possibly None) closed_at — only the cloud-bound dump
gets the synthesized timestamp.
"""
if close_reason == "mock" or getattr(session, "_mock_run", False):
return
try:
_sync(session.model_dump(mode="json"))
dump = session.model_dump(mode="json")
if not dump.get("closed_at"):
dump["closed_at"] = datetime.now().isoformat()
_sync(dump)
except Exception:
pass
+94 -3
View File
@@ -48,16 +48,45 @@ _CLAUDE_MODEL_PREFIXES = (
_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
# Bare-model patterns that resolve to Gemini's native API (gemini-3-flash-api,
# gemini-3.1-pro-api, gemini-3.1-flash-lite-api, etc. — when user supplies own
# Google API key in Settings → Models). These bypass our `gemini/` prefix so
# the prefix-only check above misses them; we match on the bare-name shape
# here too so $schema scrubbing fires for own-key Gemini sessions.
# Pre-fix: 8/8 own-key Gemini sessions in production failed with 400 because
# JSON Schema's $schema field leaked into Google's tools[].function_declarations
# payload. (See raw_payloads where status=error on every gemini-*-api session.)
_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
# Fields Gemini's function_declarations validator rejects. 9Router 0.3.60's
# translator strips allOf/anyOf/oneOf/const-toplevel/required but misses these.
# translator strips allOf/anyOf/oneOf/const-toplevel/required but misses
# these. Each one we've seen Gemini 400 on in production with "Unknown
# name 'X' at request.tools[N].function_declarations[N].parameters.…"
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
# JSON-Schema metadata fields Gemini's stricter validator doesn't accept.
"$schema",
"$id", # ag/gemini-3.1-pro-high session, 2026-05-08
"$ref", # JSON-Schema reference; Gemini wants inlined types
"$defs", # ditto
"definitions", # legacy alias for $defs
# Constraint fields Gemini doesn't implement.
"additionalProperties",
"propertyNames",
"patternProperties",
"exclusiveMinimum",
"exclusiveMaximum",
"const", # nested const leaks through 9Router's top-level-only strip.
"const", # nested const leaks through 9Router's top-level-only strip.
# Anthropic-specific tool-call hints not part of vanilla JSON Schema.
# Anthropic's CLI emits these on tools that benefit from response
# priming; Gemini's validator rejects all unknown keys.
"prefill", # ag/gemini-3.1-pro-high session, 2026-05-08
"enumTitles", # human-readable enum labels; OpenAI-only convention
"title", # safe to keep usually but Gemini sometimes rejects under nested arrays
"examples", # JSON-Schema 2019-09 keyword Gemini doesn't honor
"default", # often allowed but rejected in nested array.items
"readOnly",
"writeOnly",
"deprecated",
}
@@ -77,6 +106,59 @@ def _scrub_gemini_schema(node):
return node
# Models that REQUIRE max_completion_tokens instead of max_tokens.
# OpenAI's GPT-5.x family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex,
# etc.) introduced this in late 2025 — the legacy `max_tokens` field returns
# a 400 "Unsupported parameter: 'max_tokens' is not supported with this
# model. Use 'max_completion_tokens' instead." Anthropic's CLI / SDK still
# emits `max_tokens` because that's the Anthropic-format wire shape; we
# rename it on the way out for OpenAI-routed GPT-5 models.
_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
def _is_openai_max_completion_tokens_model(model: str) -> bool:
"""Match every shape a GPT-5 model name might arrive in. Includes:
- bare: "gpt-5", "gpt-5.5", "gpt-5.4-mini"
- api-suffixed: "gpt-5.5-api" (desktop's pinned-api naming)
- 9router-prefixed: "openai/gpt-5.5" (post-translation name)
- codex-routed: "cx/gpt-5.3-codex" (CLI subscription)
Anything WITHOUT "gpt-5" in the (lowercased) string is rejected.
"""
m = (model or "").strip().lower()
if not m:
return False
# Strip common routing prefixes so we can match the bare model body.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
break
return any(m.startswith(p) for p in _OPENAI_MAX_COMPLETION_TOKENS_MODELS)
def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
"""Rename `max_tokens` → `max_completion_tokens` for GPT-5 models.
Bytes-in/out, never raises. No-op if the body isn't JSON or doesn't
contain `max_tokens`. Drops the legacy field if BOTH are present so
the API doesn't reject for "both fields specified".
"""
if not body:
return body
try:
parsed = json.loads(body)
except Exception:
return body
if not isinstance(parsed, dict):
return body
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
return json.dumps(parsed).encode("utf-8")
if "max_tokens" in parsed and "max_completion_tokens" in parsed:
parsed.pop("max_tokens", None)
return json.dumps(parsed).encode("utf-8")
return body
def _scrub_request_for_gemini(body: bytes) -> bytes:
"""Strip Gemini-incompatible schema keys from request tools. Bytes-in/out, never raises."""
if not body:
@@ -122,7 +204,14 @@ def _is_claude_model(model: str) -> bool:
def _is_gemini_model(model: str) -> bool:
m = (model or "").strip().lower()
return m.startswith(_GEMINI_MODEL_PREFIXES)
if m.startswith(_GEMINI_MODEL_PREFIXES):
return True
# Bare-name match: "gemini-3-flash-api", "gemini-3.1-pro-api", etc.
# Excludes anthropic-routed gemini models (those carry "/" or other
# routing prefixes via the registry).
if "/" in m:
return False
return any(m.startswith(p) for p in _GEMINI_BARE_MODEL_PATTERNS)
def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
@@ -174,6 +263,8 @@ async def proxy(rest: str, request: Request):
if _is_gemini_model(model):
body = _scrub_request_for_gemini(body)
if _is_openai_max_completion_tokens_model(model):
body = _scrub_request_for_openai_gpt5(body)
base_url, auth_headers = _pick_upstream(model)
+154
View File
@@ -0,0 +1,154 @@
"""Tiny OpenAI-API pass-through with `max_tokens` → `max_completion_tokens`
rename for GPT-5.x models.
Why this exists
---------------
OpenAI's GPT-5 family (gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, etc.)
rejects the legacy `max_tokens` parameter with HTTP 400:
"Unsupported parameter: 'max_tokens' is not supported with this model.
Use 'max_completion_tokens'."
Anthropic's CLI emits requests in Anthropic format (which uses `max_tokens`),
9Router 0.3.60 translates Anthropic→OpenAI and preserves `max_tokens`
(it doesn't know about the GPT-5 change). We can't bump 9Router because
0.3.60 is pinned to fix a separate WebSearch regression in the 0.3.x
range (see backend/apps/nine_router.py:27-36).
So we slot a thin proxy between 9Router and api.openai.com. The CLI is
unaware: it sees its OPENAI_BASE_URL pointing at this local passthrough,
not OpenAI. We rename the field for GPT-5 models and forward unchanged
otherwise. Streaming + non-streaming both work because we proxy bytes.
Mounted at `/api/openai-passthrough` and consumed by setting
OPENAI_BASE_URL to `http://127.0.0.1:<port>/api/openai-passthrough/v1`
in the CLI's spawn env (see agent_manager.py).
"""
import json
import logging
from contextlib import asynccontextmanager
import httpx
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
@asynccontextmanager
async def openai_passthrough_lifespan():
yield
openai_passthrough = SubApp("openai-passthrough", openai_passthrough_lifespan)
# Models that REQUIRE max_completion_tokens. Mirrors anthropic_proxy.py's
# matcher but lives here so this module doesn't depend on that one.
_GPT5_PREFIXES = ("gpt-5",)
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
_HOP_HEADERS = {
"host", "content-length", "connection", "keep-alive",
"proxy-authenticate", "proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade",
}
def _is_gpt5(model: str) -> bool:
m = (model or "").strip().lower()
if not m:
return False
# Strip routing prefixes 9Router may have added.
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
if m.startswith(prefix):
m = m[len(prefix):]
break
return any(m.startswith(p) for p in _GPT5_PREFIXES)
def _scrub_max_tokens(body: bytes) -> bytes:
"""Rename max_tokens → max_completion_tokens for GPT-5 models.
Bytes-in/out, never raises. No-op if body isn't JSON, model isn't GPT-5,
or max_tokens isn't present. If both fields are present (unlikely),
drops the legacy field so OpenAI doesn't 400 on the conflict.
"""
if not body:
return body
try:
parsed = json.loads(body)
except Exception:
return body
if not isinstance(parsed, dict):
return body
model = str(parsed.get("model") or "")
if not _is_gpt5(model):
return body
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
return json.dumps(parsed).encode("utf-8")
if "max_tokens" in parsed and "max_completion_tokens" in parsed:
parsed.pop("max_tokens", None)
return json.dumps(parsed).encode("utf-8")
return body
@openai_passthrough.router.api_route(
"/v1/{rest:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def passthrough(rest: str, request: Request):
body = await request.body()
body = _scrub_max_tokens(body)
forward_headers: dict[str, str] = {}
for k, v in request.headers.items():
if k.lower() in _HOP_HEADERS:
continue
forward_headers[k] = v
upstream_url = f"{_OPENAI_UPSTREAM}/{rest}"
if request.url.query:
upstream_url = f"{upstream_url}?{request.url.query}"
# Stream upstream response body straight back to the caller. httpx's
# streaming context handles Server-Sent Events the CLI uses for chat
# completions without buffering the full response in memory.
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=30.0))
try:
upstream_req = client.build_request(
request.method,
upstream_url,
headers=forward_headers,
content=body,
)
upstream_resp = await client.send(upstream_req, stream=True)
except httpx.HTTPError as e:
await client.aclose()
logger.warning("openai-passthrough upstream error: %s", e)
return JSONResponse(
{"error": {"message": str(e), "type": "upstream_error"}},
status_code=502,
)
response_headers: dict[str, str] = {}
for k, v in upstream_resp.headers.items():
if k.lower() in _HOP_HEADERS:
continue
response_headers[k] = v
async def streamer():
try:
async for chunk in upstream_resp.aiter_raw():
yield chunk
finally:
await upstream_resp.aclose()
await client.aclose()
return StreamingResponse(
streamer(),
status_code=upstream_resp.status_code,
headers=response_headers,
)
+13 -7
View File
@@ -66,24 +66,30 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
{"value": "gpt-5.3-codex-xhigh", "label": "GPT-5.3 Codex Extra High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-xhigh",
"api": "codex", "subscription_only": True, "reasoning": True},
# API-key entries: bypass 9Router, call api.openai.com directly.
# API-key entries: route through 9Router's `cp-openai` provider-node
# (registered by sync_openai_api_key) so 9Router's translator
# dispatches to our local openai-passthrough proxy. The passthrough
# renames `max_tokens` → `max_completion_tokens` before forwarding
# to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare
# router_model_id (e.g. "gpt-5.5") still appears in the request
# body; only the routing prefix changes.
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.5", "model_id": "gpt-5.5",
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-mini-api", "label": "GPT-5.4 Mini (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.4-mini", "model_id": "gpt-5.4-mini",
"context_window": 400_000, "router_model_id": "cp-openai/gpt-5.4-mini", "model_id": "gpt-5.4-mini",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"context_window": 400_000, "router_model_id": "cp-openai/gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-high-api", "label": "GPT-5.3 Codex High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-high", "model_id": "gpt-5.3-codex-high",
"context_window": 400_000, "router_model_id": "cp-openai/gpt-5.3-codex-high", "model_id": "gpt-5.3-codex-high",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-xhigh-api", "label": "GPT-5.3 Codex Extra High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-xhigh", "model_id": "gpt-5.3-codex-xhigh",
"context_window": 400_000, "router_model_id": "cp-openai/gpt-5.3-codex-xhigh", "model_id": "gpt-5.3-codex-xhigh",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini 3.x thoughtSignature continuity is bypassed via 9Router's
+1 -1
View File
@@ -78,7 +78,7 @@ def _sync_identity_to_service(settings_obj) -> None:
class SigninActivateRequest(BaseModel):
token: str
signin_method: Literal["google"]
signin_method: Literal["google", "email"]
email: Optional[str] = None
+92
View File
@@ -195,6 +195,98 @@ async def seed_demo(dashboard_id: str):
return {"session_id": session_id}
@dashboards.router.post("/{dashboard_id}/seed-orchestration-demo")
async def seed_orchestration_demo(dashboard_id: str):
"""Create a stubbed "research agent" used by onboarding step 6.
Step 6 ("Have an agent control other agents") needs a pre-existing
agent for the user to attach to a new orchestrator. We seed a single
completed-looking session that pretends to have done research on
OpenSwarm, with messages mentioning what it found. The user then
drags it into a new agent and asks for a PDF report — which
delegates back to this seeded agent.
"""
_load(dashboard_id) # validate dashboard exists
session_id = uuid4().hex
now = datetime.now()
session_data = {
"id": session_id,
"name": "OpenSwarm research",
"status": "completed",
"provider": "anthropic",
"model": "sonnet",
"mode": "agent",
"sdk_session_id": None,
"system_prompt": None,
"allowed_tools": [],
"max_turns": None,
"cwd": None,
"created_at": now.isoformat(),
"closed_at": now.isoformat(),
"cost_usd": 0.0,
"tokens": {"input": 0, "output": 0},
"messages": [
{
"id": uuid4().hex,
"role": "user",
"content": "Research OpenSwarm and summarize what it does, who uses it, and how its built.",
"timestamp": now.isoformat(),
"branch_id": "main",
"parent_id": None,
"hidden": False,
},
{
"id": uuid4().hex,
"role": "assistant",
"content": (
"Here's what I found on OpenSwarm:\n\n"
"**What it is.** OpenSwarm is a desktop AI workspace built around\n"
"agents that can read and write files, run commands, browse the web,\n"
"and orchestrate other agents. It's distributed as an Electron app\n"
"with a React frontend, a Python backend, and a Hono cloud service.\n\n"
"**Who uses it.** Software engineers, researchers, and power users\n"
"who want a model-agnostic agent platform on their own machine\n"
"rather than a locked-in cloud chatbot.\n\n"
"**How it's built.**\n"
"- React + MUI + Redux Toolkit for the renderer.\n"
"- FastAPI Python backend (agents, tools, sessions).\n"
"- A Hono cloud service handles auth, billing, and account pooling.\n"
"- Built-in browser cards let agents drive web pages directly.\n"
"- Skills and Apps let users teach the system new capabilities.\n\n"
"Ready when you are — let me know what you'd like to do with this."
),
"timestamp": now.isoformat(),
"branch_id": "main",
"parent_id": None,
"hidden": False,
},
],
"pending_approvals": [],
"branches": {
"main": {
"id": "main",
"parent_branch_id": None,
"fork_point_message_id": None,
"created_at": now.isoformat(),
}
},
"active_branch_id": "main",
"tool_group_meta": {},
"dashboard_id": dashboard_id,
"browser_id": None,
"parent_session_id": None,
"needs_fork": False,
}
os.makedirs(SESSIONS_DIR, exist_ok=True)
with open(os.path.join(SESSIONS_DIR, f"{session_id}.json"), "w") as f:
json.dump(session_data, f, indent=2)
return {"session_id": session_id}
@dashboards.router.post("/{dashboard_id}/generate-name")
async def generate_name(dashboard_id: str):
dashboard = _load(dashboard_id)
+128 -3
View File
@@ -33,6 +33,12 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
# translator changes broke that path — non-Claude primaries now see
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output.
# Pinning to 0.3.60 restores v1.0.25 behavior.
#
# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens)
# when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects.
# The fix lives in our /api/openai-passthrough proxy — see openai_passthrough.py
# and sync_openai_api_key for how the translation lane is rerouted via an
# `openai-compatible` provider-node that honors `baseUrl`.
NINE_ROUTER_NPM_VERSION = "0.3.60"
_process: subprocess.Popen | None = None
@@ -488,11 +494,130 @@ async def sync_gemini_api_key(api_key: str | None) -> None:
async def sync_openai_api_key(api_key: str | None) -> None:
"""Mirror openai_api_key into 9Router; without it, route=api GPT entries 401."""
await _sync_apikey_provider(
"openai", api_key, NINE_ROUTER_OPENAI_KEYED_NAME, label="OpenAI"
"""Mirror openai_api_key into 9Router as an `openai-compatible` provider
node pointed at our local /api/openai-passthrough proxy.
Why not the built-in `openai` provider type: 9Router 0.3.60 hardcodes
`https://api.openai.com/v1` for the `openai` provider and ignores any
`baseUrl` field on the connection. Only the `openai-compatible-*`
provider-node type honors `baseUrl` (verified statically against
9Router's compiled bundle). So we register our OpenAI lane AS an
openai-compatible node — same upstream protocol, different routing.
Why we route through openai-passthrough at all: OpenAI's GPT-5 family
rejects the legacy `max_tokens` parameter with HTTP 400, but every
9Router version (including 0.4.20) emits `max_tokens` in its
Anthropic→OpenAI translator. The passthrough renames it to
`max_completion_tokens` for `gpt-5*` models before forwarding to
api.openai.com. Pre-fix: every gpt-5.* own-key session 400'd silently.
Companion change: the registry entries `gpt-5.*-api` are routed via
the `cp-openai/<model>` prefix (set by NINE_ROUTER_OPENAI_KEYED_PREFIX
below) so 9Router's translator dispatches to this provider-node
instead of the built-in `openai` provider.
"""
await _sync_openai_compat_node(api_key)
# Reserved prefix that registry.py's gpt-5.*-api router_model_ids depend on.
# Changing this breaks model resolution for OpenAI own-key users.
NINE_ROUTER_OPENAI_KEYED_PREFIX = "cp-openai"
async def _sync_openai_compat_node(api_key: str | None) -> None:
"""Create / update / delete the openai-compatible node + connection
pair we use to ferry OpenAI requests through openai-passthrough."""
if not is_running():
return
import os as _os
port = _os.environ.get("OPENSWARM_PORT", "8324")
base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1"
managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}"
# List existing managed nodes — we own the prefix `cp-openai`.
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{NINE_ROUTER_API}/provider-nodes")
existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or []
except Exception as e:
logger.warning(f"9Router OpenAI-compat node list failed: {e}")
return
existing_node = next(
(n for n in existing_nodes if isinstance(n, dict) and n.get("prefix") == NINE_ROUTER_OPENAI_KEYED_PREFIX),
None,
)
# Tear down when api_key is cleared.
if not api_key:
if existing_node:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}")
logger.info("9Router: removed OpenAI compat node (key cleared)")
except Exception as e:
logger.warning(f"9Router OpenAI compat delete failed: {e}")
return
node_payload = {
"name": managed_name,
"prefix": NINE_ROUTER_OPENAI_KEYED_PREFIX,
"apiType": "chat",
"baseUrl": base_url,
"type": "openai-compatible",
}
node_id: str | None = existing_node.get("id") if existing_node else None
try:
async with httpx.AsyncClient(timeout=5.0) as client:
if existing_node:
await client.put(
f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}",
json=node_payload,
)
logger.info(f"9Router: updated OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX}")
else:
r = await client.post(
f"{NINE_ROUTER_API}/provider-nodes", json=node_payload,
)
if r.status_code >= 300:
logger.warning(
f"9Router: failed to create OpenAI compat node: "
f"{r.status_code} {r.text[:200]}"
)
return
node_id = (r.json() or {}).get("node", {}).get("id")
if not node_id:
return
logger.info(f"9Router: created OpenAI compat node {NINE_ROUTER_OPENAI_KEYED_PREFIX} ({node_id})")
except Exception as e:
logger.warning(f"9Router OpenAI compat node sync failed: {e}")
return
# Connection record carrying the api key, scoped to this provider node.
try:
existing_conn = await _find_keyed_connection(node_id, managed_name)
conn_payload = {
"provider": node_id,
"authType": "apikey",
"name": managed_name,
"apiKey": api_key,
"priority": 0,
}
async with httpx.AsyncClient(timeout=5.0) as client:
if existing_conn:
await client.patch(
f"{NINE_ROUTER_API}/providers/{existing_conn['id']}",
json=conn_payload,
)
else:
r = await client.post(f"{NINE_ROUTER_API}/providers", json=conn_payload)
if r.status_code >= 300:
logger.warning(
f"9Router: failed to create OpenAI compat connection: "
f"{r.status_code} {r.text[:200]}"
)
except Exception as e:
logger.warning(f"9Router OpenAI compat connection sync failed: {e}")
async def sync_openrouter_api_key(api_key: str | None) -> None:
"""Mirror openrouter_api_key into 9Router; supplies bearer for openrouter/ routes."""
+29 -4
View File
@@ -141,11 +141,36 @@ def _envelope() -> dict:
env["device_type"] = "desktop"
except Exception:
pass
# Timezone: prefer the IANA zone name passed in by Electron (always
# canonical, e.g. "America/Los_Angeles") so cloud-side localTimeFields()
# can format hour-of-day correctly. Fall back to Python's local zone
# which sometimes returns abbreviations (PDT, CDT) or localized names
# ("Romance (zomertijd)") that don't round-trip through tzdata.
try:
import datetime as _dt
local_tz = _dt.datetime.now().astimezone().tzinfo
if local_tz:
env["timezone"] = str(local_tz)
ianatz = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not ianatz:
try:
from tzlocal import get_localzone_name # type: ignore
ianatz = get_localzone_name() or ""
except Exception:
pass
if not ianatz:
import datetime as _dt
local_tz = _dt.datetime.now().astimezone().tzinfo
if local_tz:
ianatz = str(local_tz)
if ianatz:
env["timezone"] = ianatz
except Exception:
pass
# Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via
# app.getLocale() — see electron/main.js. We don't fall back to Python's
# locale.getdefaultlocale() because that's deprecated, often empty, and
# returns inconsistent OS-specific values across macOS/Windows/Linux.
try:
loc = os.environ.get("OPENSWARM_LOCALE", "").strip()
if loc:
env["locale"] = loc
except Exception:
pass
try:
+39 -4
View File
@@ -30,6 +30,19 @@ logger = logging.getLogger(__name__)
def _read_app_version() -> str:
# Preferred: Electron's main process injects this when spawning the
# backend (see electron/main.js — OPENSWARM_APP_VERSION). Always reliable
# in packaged builds because it comes from app.getVersion() rather than
# path-based file resolution.
env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
if env_v:
return env_v
# Fallback: read electron/package.json via relative path. Works in
# `bash run.sh` dev mode where the repo layout is intact, but FAILS in
# packaged dmg/exe builds because electron/package.json isn't shipped
# into Resources/ — which made every shipped install report
# app_version="unknown" pre-fix. Kept for backward compatibility with
# dev runs and as a safety net if the env var is ever unset.
try:
_here = os.path.dirname(os.path.abspath(__file__))
_repo = os.path.dirname(os.path.dirname(os.path.dirname(_here)))
@@ -395,12 +408,34 @@ async def service_status():
@service.router.post("/submit")
async def post_submit(body: dict):
"""Accepts two body shapes for backward compatibility:
1. Frontend `report()` shape — flat `{s, a, p, submission_id, t}`.
This is what `frontend/src/shared/serviceClient.ts:report()` sends
on every UI interaction. Pass through unchanged so the cloud sees
it as a frontend.event.
2. Legacy `{kind, payload}` shape — used by older callers that wrapped
the payload in a kind+payload envelope before submitting. Unwrap
and forward the payload.
Pre-fix this endpoint required shape #2 and silently rejected shape #1
with a 200 + `{ok:false}`, so every UI event from `report()` was
dropped — `frontend.event` count was 0 in production analytics.
"""
if not isinstance(body, dict):
return {"ok": False, "error": "JSON object required"}
# Shape 1: frontend `report()` — flat {s, a, p, ...}
if any(k in body for k in ("s", "a", "p")):
svc.sync(body)
return {"ok": True}
# Shape 2: legacy {kind, payload}
kind = body.get("kind") or ""
payload = body.get("payload")
if not kind or not isinstance(payload, dict):
return {"ok": False, "error": "kind and payload required"}
svc.sync(payload)
return {"ok": True}
if kind and isinstance(payload, dict):
svc.sync(payload)
return {"ok": True}
return {"ok": False, "error": "expected {s,a,p,...} or {kind,payload}"}
@service.router.post("/event")
+47 -6
View File
@@ -6,10 +6,11 @@ messages) over WebSockets. Without auth, any webpage loaded in any
browser on the same machine can connect to those endpoints — WebSockets
aren't subject to Same-Origin Policy — and impersonate the user.
This module issues a cryptographically random token at backend startup,
writes it 0600 to `<DATA_ROOT>/auth.token`, and provides validation
helpers. The token changes every backend restart. Only code running as
the same OS user can read the file.
This module issues a cryptographically random token on first boot,
writes it 0600 to `<DATA_ROOT>/auth.token`, and reuses it on subsequent
restarts (so dev-mode hot-reload doesn't break the renderer's cached
copy). The token is regenerated only when the file is missing or empty.
Only code running as the same OS user can read the file.
Delivery to legitimate consumers:
@@ -59,11 +60,44 @@ def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
def init_auth_token() -> str:
"""Generate a fresh token, persist to disk, return it.
"""Initialise the per-install auth token, persisting to disk.
Called once at backend startup before the HTTP port is bound.
Behaviour: prefer an existing token on disk; only mint a fresh one
when the file is absent or empty. This matters for two cases:
1. Dev mode (`bash run.sh`) — uvicorn's WatchFiles reload restarts
the worker process and re-runs init_auth_token. If we generated
a fresh token every reload, Electron's cached token (read once
at app boot) would mismatch and every authed request 401s
until the user fully restarts. Preserving the on-disk token
keeps Electron and the backend in sync across reloads.
2. Packaged builds — the user can restart the backend (Quit + reopen)
without the renderer reloading. Same mismatch risk, same fix.
Security trade-off: we no longer rotate the token on every restart.
The threat model that rotation was protecting against (a stale token
sitting in a log/crash dump being usable later) is marginal — anyone
who can read the artifact can also re-read the on-disk token, and
real rotation requires the file to be deleted (e.g. by signing out
or wiping the data root). Net: dev-mode reliability wins.
"""
global _TOKEN
# Try existing on-disk token first.
try:
if os.path.exists(AUTH_TOKEN_FILE):
with open(AUTH_TOKEN_FILE, "r", encoding="utf-8") as f:
existing = f.read().strip()
if existing and 16 <= len(existing) <= 512:
_TOKEN = existing
logger.info(
f"auth: reusing existing token from {AUTH_TOKEN_FILE}"
)
return _TOKEN
except Exception as e:
# Fall through to fresh generation on any read error.
logger.warning(f"auth: failed to read existing token, generating new: {e}")
_TOKEN = secrets.token_urlsafe(32)
try:
_write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600)
@@ -118,6 +152,13 @@ _AUTH_EXEMPT_PREFIX = (
# covered without re-introducing the bootstrap deadlock that an
# exact "/api/health" match caused.
"/api/health",
# OpenAI API pass-through. 9Router calls this with the user's
# OpenAI Bearer token (sk-…), NOT our local auth token, so our
# middleware would reject. Localhost-only network boundary is the
# security gate — the route only forwards to api.openai.com and
# never touches user data on this machine. See
# backend/apps/agents/openai_passthrough.py for why this exists.
"/api/openai-passthrough",
# FastAPI's default health/docs/schema surface (packaged app never
# ships /docs, but be defensive).
"/docs",
+4
View File
@@ -16,6 +16,10 @@ python-dotenv==1.1.1
Pillow
httpx>=0.27.0
trafilatura
# tzlocal: dev-mode fallback for resolving the user's IANA timezone when
# Electron's OPENSWARM_TIMEZONE env var isn't set (i.e. `bash run.sh`).
# Packaged builds get the env var directly so this is a safety net.
tzlocal
# Test deps (pytest, pytest-asyncio) live in requirements-dev.txt — they
# never ship to production users and shaved ~3 MB / ~200 files off the
# Mac DMG when removed from the prod env.
+37
View File
@@ -467,6 +467,22 @@ async function startBackend() {
OPENSWARM_PORT: String(backendPort),
OPENSWARM_ELECTRON_PATH: process.execPath,
OPENSWARM_INSTALL_METHOD: installMethod,
// Inject the app version so the Python backend can report it in the
// analytics envelope. Without this, _read_app_version() in
// service/service.py tries to read electron/package.json via a relative
// path that resolves correctly in `bash run.sh` dev mode but fails in
// packaged dmg/exe builds — which made every shipped install report
// app_version="unknown". The path-based fallback stays in place so this
// change is purely additive.
OPENSWARM_APP_VERSION: app.getVersion(),
// Inject the user's BCP 47 locale + IANA timezone. The Python backend
// doesn't have reliable APIs for either: locale.getdefaultlocale() is
// deprecated and inconsistent across OSes, and Python's local-tz string
// sometimes returns "PDT" or "Romance (zomertijd)" rather than
// "America/Los_Angeles". Electron has both in canonical form via
// app.getLocale() and Intl.DateTimeFormat().resolvedOptions().timeZone.
OPENSWARM_LOCALE: app.getLocale(),
OPENSWARM_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
PYTHONDONTWRITEBYTECODE: '1',
// PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where
// the locale is otherwise cp1252. Many backend modules read UTF-8
@@ -666,6 +682,27 @@ function createWindow() {
mainWindow.on('closed', () => {
mainWindow = null;
});
// Window-blur / window-focus tracking — analytics signal for "user
// switched to another app" (temp-churn). The renderer captures these
// through the existing report() pipeline; we just emit IPC notices
// here so the React layer can timestamp them and forward to the
// local backend's /api/service/submit endpoint.
//
// Cadence: at most once every 2 seconds per direction. Without that
// throttle, dragging a window across desktops or having a popup steal
// focus generates a burst of blur/focus pairs that pollute analytics
// with noise.
let _lastFocusEvent = 0;
const FOCUS_THROTTLE_MS = 2000;
const sendFocusEvent = (kind) => {
const now = Date.now();
if (now - _lastFocusEvent < FOCUS_THROTTLE_MS) return;
_lastFocusEvent = now;
sendToRenderer('openswarm:window-focus', { kind, ts: now });
};
mainWindow.on('blur', () => sendFocusEvent('blur'));
mainWindow.on('focus', () => sendFocusEvent('focus'));
}
function sendToRenderer(channel, ...args) {
+10
View File
@@ -81,6 +81,16 @@ const { contextBridge, ipcRenderer } = require('electron');
return () => ipcRenderer.removeListener('openswarm:oauth-claim', listener);
},
// Window blur/focus events — analytics signal for "user switched
// to another app" (temp-churn measurement). Throttled in main.js to
// at most once per 2s per direction so OS-level focus storms don't
// pollute the event stream.
onWindowFocus: (cb) => {
const listener = (_event, payload) => cb(payload);
ipcRenderer.on('openswarm:window-focus', listener);
return () => ipcRenderer.removeListener('openswarm:window-focus', listener);
},
// OAuth popup callback. Fires when any child webContents navigates to
// localhost:20128/callback?code=... — main.js watches for this and
// forwards the parsed params here. Used as a belt-and-suspenders
+21 -96
View File
@@ -27,12 +27,15 @@ const Modes = lazy(() => import('./pages/Modes/Modes'));
const Views = lazy(() => import('./pages/Views/Views'));
const Customization = lazy(() => import('./pages/Customization/Customization'));
const Analytics = lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingModal = lazy(() => import('./components/OnboardingModal'));
const OnboardingRoot = lazy(() =>
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
);
const SignInGate = lazy(() => import('./components/SignInGate'));
import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient';
import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import { useDeepLink } from '@/shared/hooks/useDeepLink';
import { useWindowFocus } from '@/shared/hooks/useWindowFocus';
import { useInteractionHeartbeat } from '@/shared/hooks/useInteractionHeartbeat';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -166,6 +169,8 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
// Window blur/focus → analytics events (temp-churn signal).
useWindowFocus();
// Single global interaction-timestamp recorder. Powers idle-dim and
// similar UX, and gives the session-close dump a real "last user
// interaction" timestamp.
@@ -211,119 +216,39 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
};
// Sign-in gate. Sits between SettingsLoader and DefaultModelGuard so the
// gate is the very first thing a user without a user_id sees. Two modes:
// gate is the very first thing a user without a user_id sees.
//
// - Hard gate (fresh installs, existing installs past the 30-day grace):
// Modal blocks the app until sign-in completes. No skip link.
// - Soft gate (existing installs inside the grace window): Modal can be
// dismissed. settings.signin_skipped_until_ts persists "remind me in 7
// days." On the next launch after that timestamp, the modal returns.
//
// Already-signed-in users (settings.user_id != null) skip the gate. Existing
// paid Stripe users without explicit sign-in also skip — their bearer is
// valid even though user_id might not have been backfilled yet (deferred
// to a one-time /api/me hit on the v1.0.29 first-launch). For the simple
// case we treat openswarm_bearer_token alone as "signed in" so paying
// customers never see the gate.
interface IdentityStatus {
authed: boolean;
hard_gate: boolean;
install_age_days?: number;
deadline_ts?: number | null;
}
// In v2 the gate is **mandatory** — no skip link, no soft/hard split.
// The user must sign in (Google or email/password+verification code) before
// the rest of the app is interactive. Already-signed-in users skip the gate.
// Existing paid Stripe users without explicit user_id also skip — their
// bearer is valid even though user_id might not be backfilled yet.
const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const [status, setStatus] = useState<IdentityStatus | null>(null);
const [skipTs, setSkipTs] = useState<number>(0);
// Already authenticated, either via the new sign-in flow (user_id set) or
// a still-valid Stripe bearer (paid user upgrading from v1.0.29).
const alreadySignedIn = Boolean(settings.user_id || settings.openswarm_bearer_token);
// First-time-installs run sign-in INSIDE OnboardingModal as step 1 — we
// don't want a separate gate competing with the onboarding flow. The
// gate is only for *post-onboarding* signed-out users (e.g. they signed
// out from Settings, or they're an existing v1.0.28 user upgrading to
// v1.0.29 and never went through onboarding because openswarm_onboarding_seen
// was already true). In both of those cases SignInGate is the right UI.
const onboardingSeen = (() => {
try { return window.localStorage.getItem('openswarm_onboarding_seen') === 'true'; }
catch { return false; }
})();
const deferToOnboarding = !onboardingSeen;
useEffect(() => {
if (!settingsLoaded) return;
if (alreadySignedIn) {
setStatus({ authed: true, hard_gate: false });
return;
}
let cancelled = false;
fetch(`${API_BASE}/auth/identity-status`)
.then((r) => (r.ok ? r.json() : { authed: false, hard_gate: true }))
.then((data) => {
if (!cancelled) setStatus(data as IdentityStatus);
})
.catch(() => {
// Cloud unreachable → fail open with soft gate so the user can keep
// working. The gate will retry on next mount.
if (!cancelled) setStatus({ authed: false, hard_gate: false });
});
return () => { cancelled = true; };
}, [settingsLoaded, alreadySignedIn]);
// While the gate is showing, poll settings every 2s so the moment the
// sign-in flow completes (browser POSTs /api/auth/signin-activate, local
// backend persists user_id to settings.json), we re-read settings and
// the gate auto-dismisses without the user clicking anything. Cheap —
// /api/settings is a static file read on the local backend. Stops as
// soon as the user is authed or the user has skipped.
// Poll settings every 2s while the gate is up so the moment the sign-in
// flow completes (browser POSTs /api/auth/signin-activate, local backend
// persists user_id to settings.json), we re-read settings and the gate
// auto-dismisses without the user clicking anything.
useEffect(() => {
if (!settingsLoaded || alreadySignedIn) return;
if (status?.authed) return;
const id = setInterval(() => { dispatch(fetchSettings()); }, 2000);
return () => clearInterval(id);
}, [dispatch, settingsLoaded, alreadySignedIn, status?.authed]);
}, [dispatch, settingsLoaded, alreadySignedIn]);
// Read the persisted "remind me later" timestamp from localStorage so
// soft-gate skip survives reloads but doesn't bloat AppSettings.
useEffect(() => {
try {
const raw = window.localStorage.getItem('openswarm_signin_skipped_until');
const n = raw ? parseInt(raw, 10) : 0;
if (Number.isFinite(n)) setSkipTs(n);
} catch { /* localStorage unavailable */ }
}, []);
if (!settingsLoaded || !status) return null;
if (status.authed) return <>{children}</>;
// Onboarding hasn't completed yet — let OnboardingModal handle sign-in.
// Render children so OnboardingModal (mounted as a sibling) can paint
// over them with its own modal.
if (deferToOnboarding) return <>{children}</>;
const skipActive = !status.hard_gate && Date.now() < skipTs;
if (skipActive) return <>{children}</>;
if (!settingsLoaded) return null;
if (alreadySignedIn) return <>{children}</>;
return (
<>
{children}
<Suspense fallback={null}>
<SignInGate
softGate={!status.hard_gate}
onSkip={() => {
// 7-day reminder window for soft gate.
const until = Date.now() + 7 * 24 * 60 * 60 * 1000;
try {
window.localStorage.setItem('openswarm_signin_skipped_until', String(until));
} catch { /* ignore */ }
setSkipTs(until);
}}
/>
<SignInGate />
</Suspense>
</>
);
@@ -529,7 +454,7 @@ const ThemedApp: React.FC = () => {
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<OnboardingModal />
<OnboardingRoot />
</Suspense>
</DeepLinkListener>
</UpdateListener>
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useRef, useCallback, useMemo, MutableRefObject } from 'react';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
export interface SelectedElement {
id: string;
@@ -70,6 +71,13 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.id === el.id)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Same onboarding-bus emit as addElementForOwner. Drag-select goes
// through THIS path (via useDomElementSelector → ctx.addSelectedElement),
// not addElementForOwner — so without this branch, step 5 / 6's
// wait-for-attached event never fires when the user actually drags.
if (el.semanticType === 'browser-card' || el.semanticType === 'agent-card') {
onboardingBus.emit('agent:attached_to_browser');
}
}, []);
const updateSelectedElement = useCallback((id: string, patch: Partial<SelectedElement>) => {
@@ -107,6 +115,17 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.semanticData?.selectId === el.semanticData?.selectId)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Surface the attachment to the onboarding bus. Step 5 ("have an
// agent use the browser") and step 6 ("have an agent control other
// agents") both wait on this event after the user repeats the
// drag-select gesture. Both element kinds (browser-card / agent-card)
// resolve the same wait — the runtime doesn't differentiate.
if (
el.semanticType === 'browser-card' ||
el.semanticType === 'agent-card'
) {
onboardingBus.emit('agent:attached_to_browser');
}
}, []);
const removeOwnerElement = useCallback((ownerId: string, elementId: string) => {
@@ -619,6 +619,7 @@ const AppShell: React.FC = () => {
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
data-onboarding="sidebar-dashboards"
sx={{
borderRadius: 1.5,
py: 0.6,
@@ -684,12 +685,20 @@ const AppShell: React.FC = () => {
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{dashboardList.map((entry) => {
{dashboardList.map((entry, idx) => {
const isActive = activeDashboardId === entry.id;
const isRenaming = renamingDashboardId === entry.id;
return (
<Box
key={entry.id}
// Onboarding targets: every row carries a stable id so
// the AC can point at a specific dashboard, plus the
// first row gets a generic "first" alias so the AC
// can teach "click into a dashboard" without knowing
// any specific id.
data-onboarding={
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
}
onClick={() => handleDashboardItemClick(entry.id)}
sx={{
display: 'flex',
@@ -982,6 +991,7 @@ const AppShell: React.FC = () => {
>
<ListItemButton
onClick={() => dispatch(openSettingsModal())}
data-onboarding="sidebar-settings-button"
sx={{
borderRadius: 1.5,
py: 0.6,
@@ -0,0 +1,196 @@
// Singleton glue between the Onboarding panel UI and the AC runtime.
//
// Lifecycle:
// - OnboardingRoot mounts, calls Director.attach({ acRef, store, getAccentColor })
// - Panel "Show me" click → Director.startStep(stepId, sourceRect)
// - Director creates an AbortController, hands off to acRuntime.runStep
// - User dismisses panel mid-step → Director.cancelStep() → controller.abort()
//
// The runtime is the only place that touches the cursor handle directly.
// The Director is just a thin policy layer — it picks the spawn point,
// resolves dependencies, and translates Redux state into "should we walk
// step 4 again before step 5."
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
import type { RefObject } from 'react';
import { runStep } from './ac/acRuntime';
import type { AgenticCursorHandle } from './ac/AgenticCursor';
import type { OnboardingStep } from './steps/types';
import { STEPS, findStepById } from './steps';
import { API_BASE } from '@/shared/config';
import { report } from './telemetry';
interface AttachArgs {
acRef: RefObject<AgenticCursorHandle | null>;
store: Store<RootState>;
getAccentColor: () => string;
// Resolves whether a dependency's outcome is still satisfied. If true,
// the dependency's flow is skipped during walk_again. Step-5's depCheck,
// for example, asks "is there still a live browser card on the canvas?"
isDependencySatisfied: (depId: string) => boolean;
}
class OnboardingDirector {
private acRef: RefObject<AgenticCursorHandle | null> | null = null;
private store: Store<RootState> | null = null;
private getAccentColor: () => string = () => '#E8927A';
private isDependencySatisfied: (depId: string) => boolean = () => false;
private currentAbort: AbortController | null = null;
attach(args: AttachArgs) {
this.acRef = args.acRef;
this.store = args.store;
this.getAccentColor = args.getAccentColor;
this.isDependencySatisfied = args.isDependencySatisfied;
}
detach() {
this.cancelStep();
this.acRef = null;
this.store = null;
}
isRunning(): boolean {
return this.currentAbort !== null;
}
cancelStep(): void {
if (this.currentAbort) {
this.currentAbort.abort();
this.currentAbort = null;
}
}
async startStep(
stepId: string,
spawnPoint: { x: number; y: number },
): Promise<void> {
if (!this.acRef || !this.store) {
console.warn('[onboarding] Director not attached');
return;
}
const ac = this.acRef.current;
if (!ac) {
console.warn('[onboarding] AC ref not yet mounted');
return;
}
const step = findStepById(stepId);
if (!step) {
console.warn('[onboarding] step not found', stepId);
return;
}
this.cancelStep();
const controller = new AbortController();
this.currentAbort = controller;
// Adaptive abort hooks — fire controller.abort() so the runtime's
// existing cleanup path takes over (cursor outros, popup retreats,
// panel re-shows for the user to re-attempt).
//
// 1. Lost target — tracker fires this when its cached element has
// been disconnected for >2.5s (user navigated away, collapsed
// the section, swapped a card out from under us).
// 2. Hash-route change — user clicked a sidebar entry / dashboard
// item / settings link mid-flow. Capture the route at start time
// and abort if it changes; lets the user explore freely without
// the AC stranding itself on the wrong page.
const startHash = window.location.hash;
const onLost = () => {
report('step_aborted_lost_target', { step_id: stepId });
controller.abort();
};
const onRouteChange = () => {
if (window.location.hash !== startHash) {
report('step_aborted_route_change', {
step_id: stepId,
from: startHash,
to: window.location.hash,
});
controller.abort();
}
};
window.addEventListener('openswarm:onboarding:lost_target', onLost);
window.addEventListener('hashchange', onRouteChange);
try {
try {
await 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,
spawnPoint,
ac,
store: this.store,
accentColor: this.getAccentColor(),
signal: controller.signal,
findStep: findStepById,
isDependencySatisfied: this.isDependencySatisfied,
});
} finally {
window.removeEventListener('openswarm:onboarding:lost_target', onLost);
window.removeEventListener('hashchange', onRouteChange);
if (this.currentAbort === controller) {
this.currentAbort = null;
}
}
}
private async runPreStepHook(step: OnboardingStep): Promise<void> {
if (!this.store) return;
if (step.id === 'agent_control_agents') {
await this.ensureStubResearchAgent();
}
}
/**
* Step 6 needs a pre-existing "research" agent on the canvas so the
* spec's "say you already have an agent that did some work for you"
* narrative makes sense. We look for any existing session named
* "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.
*/
private async ensureStubResearchAgent(): Promise<void> {
const state = this.store!.getState();
const sessions = (state as any).agents?.sessions ?? {};
const alreadySeeded = Object.values(sessions).some(
(s: any) => s?.name === 'OpenSwarm research',
);
if (alreadySeeded) return;
const dashboardId =
(state as any).tempState?.lastDashboardId ??
Object.keys((state as any).dashboards?.items ?? {})[0] ??
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);
}
}
}
export const onboardingDirector = new OnboardingDirector();
// Convenience: return the ordered roadmap (1..10) so callers don't import STEPS
// directly when they just need the schedule. STEPS itself is the source of truth.
export function getRoadmap(): OnboardingStep[] {
return STEPS;
}
@@ -0,0 +1,762 @@
// Docked top-right panel. Three visible states:
// - 'pill' — small "Finish setup X/N · Continue →" pill
// - 'expanded' — full card with title/desc/video preview/Show me + See all todos
// - 'roadmap' — full 10-step modal (delegated to OnboardingRoadmapModal)
// - 'hidden' — user-dismissed; only re-shows via Settings → Restart tour
//
// When a step completes, we render a one-time celebration overlay (check
// icon + strike-through over the title) for ~1500ms before crossfading to
// the next step's card. justCompletedStepId in Redux drives this; the
// useEffect below clears it on a timer.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, Typography, IconButton, Button, ButtonBase } from '@mui/material';
import RemoveIcon from '@mui/icons-material/Remove';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { useOnboardingProgress } from './hooks/useOnboardingProgress';
import { clearJustCompleted } from './OnboardingProgressSlice';
import { STEPS, findStepById } from './steps';
import { STAGE_LABELS } from './steps/types';
import { onboardingDirector } from './OnboardingDirector';
import { report } from './telemetry';
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
const PANEL_WIDTH = 320;
// Long enough to register the strike-through + check, short enough that
// it doesn't feel like waiting before the next step appears.
const CELEBRATION_MS = 900;
// Tiny cursor-arrow SVG that mirrors the shape rendered by AgenticCursor
// so the AC visually appears to "come to life" out of this icon when the
// user clicks Show me.
const CursorIconSmall: React.FC<{ size?: number; color: string }> = ({
size = 14,
color,
}) => (
<svg
width={size}
height={size}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
style={{ display: 'block' }}
>
<path
d="M3 2 L3 18 L7.5 14 L10 19.5 L13 18 L10.5 12.5 L17 12 Z"
fill={color}
stroke="white"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
);
const OnboardingPanel: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const progress = useOnboardingProgress();
const infoBtnRef = useRef<HTMLButtonElement | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
// 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);
// Resolve current step. Prefer explicit currentStepId; fall back to
// first uncompleted step.
const currentStep = useMemo(() => {
const explicit = progress.currentStepId
? findStepById(progress.currentStepId)
: null;
if (explicit && !progress.completedSteps.includes(explicit.id)) return explicit;
return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null;
}, [progress.currentStepId, progress.completedSteps]);
// Stage-relative progress counts. Spec mockup shows "Get started 1/6"
// (per-stage), not "1/10" (overall). The pill keeps overall.
const stageOf = currentStep?.stage ?? 'get_started';
const stageSteps = useMemo(
() => STEPS.filter((s) => s.stage === stageOf),
[stageOf],
);
const stageDone = stageSteps.filter((s) =>
progress.completedSteps.includes(s.id),
).length;
const total = STEPS.length;
const done = progress.completedSteps.length;
// Celebration banner — strike-through + check on the just-completed
// step. Auto-clears so we transition into the next step's card.
// Depend ONLY on the id (stable across renders); dispatching from
// the slice action directly avoids re-running the effect when the
// useOnboardingProgress wrapper produces a new clearJustCompleted
// reference each render (which would reset the timer endlessly).
const justDoneStepId = progress.justCompletedStepId;
const justDoneStep = justDoneStepId ? findStepById(justDoneStepId) : null;
useEffect(() => {
if (!justDoneStepId) return;
const t = window.setTimeout(() => {
dispatch(clearJustCompleted());
}, CELEBRATION_MS);
return () => window.clearTimeout(t);
}, [justDoneStepId, dispatch]);
const handleShowMe = async () => {
if (!currentStep) return;
if (progress.running) return;
const iconEl = cursorIconRef.current;
const rect = iconEl?.getBoundingClientRect();
// Sanity-check the rect: if the panel is mid-transition (Framer's
// exit animation hasn't completed), getBoundingClientRect can return
// (0,0,0,0) — which would land the cursor at the top-left corner
// (over the macOS traffic lights). Fall back to a sensible
// top-right anchor when the rect looks degenerate.
const validRect =
rect && (rect.width > 0 || rect.height > 0) && (rect.left > 0 || rect.top > 0);
const spawnPoint = validRect
? { 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 });
await onboardingDirector.startStep(currentStep.id, spawnPoint);
};
if (!currentStep && !justDoneStep) return null;
if (progress.panelMode === 'hidden') return null;
// While AC is actively walking the user through a step, the panel
// would otherwise sit on top of targets in the top-right corner
// (Skills install button, "+ New app" on the Apps page, the Apps
// toolbar button, etc). Slide it off-screen with a small fade so the
// cursor has a clean canvas; it animates back when the step outros.
// motion.div handles both directions of the transition.
const panelHidden = progress.running;
return (
<>
<Box
component={motion.div}
animate={{
x: panelHidden ? PANEL_WIDTH + 48 : 0,
opacity: panelHidden ? 0 : 1,
}}
transition={{ type: 'spring', stiffness: 280, damping: 32 }}
sx={{
position: 'fixed',
// 38px title bar (drag region with traffic lights / OpenSwarm logo)
// + 6px breathing room. Sits just below the title bar — clear of
// the logo in the right corner but tighter to it than the
// previous 54px so the pill doesn't visually float away from
// the chrome.
top: 44,
right: 16,
zIndex: 1200,
fontFamily: c.font.sans,
pointerEvents: panelHidden ? 'none' : 'auto',
}}
>
<AnimatePresence mode="wait" initial={false}>
{progress.panelMode === 'pill' && (
<motion.div
key="pill"
initial={{ opacity: 0, y: -6, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.96 }}
transition={{ duration: 0.18 }}
style={{ pointerEvents: 'auto' }}
>
<ButtonBase
onClick={() => {
report('panel_expanded', { from: 'pill' });
progress.setPanelMode('expanded');
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.4,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: 999,
py: 0.65,
pl: 1.5,
pr: 1.4,
boxShadow: '0 6px 18px rgba(0,0,0,0.10)',
textAlign: 'left',
transition: 'background 0.15s, box-shadow 0.15s',
'&:hover': {
bgcolor: c.bg.elevated ?? c.bg.surface,
boxShadow: '0 10px 24px rgba(0,0,0,0.14)',
},
}}
>
<Typography sx={{ fontSize: 13, fontWeight: 500, color: c.text.primary }}>
Finish setup
</Typography>
<Typography sx={{ fontSize: 12, color: c.text.muted }}>
{done}/{total}
</Typography>
<Box sx={{ flexGrow: 1, minWidth: 8 }} />
<Typography
sx={{
fontSize: 12.5,
fontWeight: 600,
color: c.accent.primary,
display: 'flex',
alignItems: 'center',
gap: 0.4,
}}
>
Continue
<ArrowForwardIcon sx={{ fontSize: 14 }} />
</Typography>
</ButtonBase>
</motion.div>
)}
{progress.panelMode === 'expanded' && (
<motion.div
key="expanded"
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.2 }}
style={{ pointerEvents: 'auto' }}
>
<Box
sx={{
width: PANEL_WIDTH,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: '0 12px 36px rgba(0,0,0,0.16)',
overflow: 'hidden',
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.6,
pt: 1.2,
pb: 0.75,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.7 }}>
<Typography
sx={{ fontSize: 13.5, fontWeight: 600, color: c.text.primary }}
>
{STAGE_LABELS[stageOf]}
</Typography>
<Typography sx={{ fontSize: 11.5, color: c.text.muted }}>
{stageDone}/{stageSteps.length}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => {
report('panel_minimized', { from: 'expanded' });
progress.setPanelMode('pill');
}}
sx={{ color: c.text.tertiary, p: 0.4 }}
aria-label="Minimize"
>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Body celebration overlay or current step. AnimatePresence
crossfades between them so step transitions feel smooth. */}
<Box sx={{ position: 'relative' }}>
<AnimatePresence mode="wait" initial={false}>
{justDoneStep ? (
<motion.div
key={`celebrate-${justDoneStep.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
<CelebrationView step={justDoneStep} accent={c.accent.primary} />
</motion.div>
) : currentStep ? (
<motion.div
key={`step-${currentStep.id}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
<StepCardBody
step={currentStep}
tokens={c}
cursorIconRef={cursorIconRef}
infoBtnRef={infoBtnRef}
onShowMe={handleShowMe}
onOpenRoadmap={() => {
report('roadmap_opened', { from: 'panel' });
progress.setPanelMode('roadmap');
}}
onToggleInfo={() => {
report('info_toggled', {
step_id: currentStep.id,
opening: !infoOpen,
});
setInfoOpen((v) => !v);
}}
running={progress.running}
/>
</motion.div>
) : (
<motion.div
key="all-done"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<AllDoneView accent={c.accent.primary} tokens={c} />
</motion.div>
)}
</AnimatePresence>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
</Box>
{/* Floating "?" info popover, anchored to the info icon. Renders
OUTSIDE the panel container so it can extend to the left without
clipping. */}
{infoOpen && currentStep && (
<InfoPopover
stepId={currentStep.id}
anchorRef={infoBtnRef}
onClose={() => setInfoOpen(false)}
tokens={c}
/>
)}
<OnboardingRoadmapModal />
</>
);
};
interface StepCardProps {
step: ReturnType<typeof findStepById> & {};
tokens: ReturnType<typeof useClaudeTokens>;
cursorIconRef: React.MutableRefObject<HTMLSpanElement | null>;
infoBtnRef: React.MutableRefObject<HTMLButtonElement | null>;
onShowMe: () => void;
onOpenRoadmap: () => void;
onToggleInfo: () => void;
running: boolean;
}
const StepCardBody: React.FC<StepCardProps> = ({
step,
tokens: c,
cursorIconRef,
infoBtnRef,
onShowMe,
onOpenRoadmap,
onToggleInfo,
running,
}) => {
if (!step) return null;
return (
<Box sx={{ px: 1.6, pt: 1.2, pb: 1.6 }}>
<Typography
sx={{
fontSize: 16,
fontWeight: 600,
color: c.text.primary,
mb: 0.4,
fontFamily: '"Charter", Georgia, serif',
}}
>
{step.title}
</Typography>
<Typography
sx={{
fontSize: 12.5,
color: c.text.secondary,
mb: 1.4,
lineHeight: 1.4,
}}
>
{step.description}
</Typography>
<Box
sx={{
position: 'relative',
borderRadius: `${c.radius.md}px`,
overflow: 'hidden',
aspectRatio: '16 / 9',
mb: 1.5,
background: `linear-gradient(135deg, ${c.accent.primary}22, ${c.accent.primary}08)`,
border: `1px solid ${c.border.subtle}`,
}}
>
{step.videoSrc && (
<Box
component="video"
src={step.videoSrc}
autoPlay
muted
loop
playsInline
onError={(e: React.SyntheticEvent<HTMLVideoElement>) => {
(e.currentTarget as HTMLVideoElement).style.display = 'none';
}}
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{step.videoDurationLabel && (
<Box
sx={{
position: 'absolute',
top: 8,
left: 8,
bgcolor: 'rgba(0,0,0,0.55)',
color: '#fff',
fontSize: 10.5,
fontWeight: 600,
px: 0.8,
py: 0.2,
borderRadius: 999,
backdropFilter: 'blur(2px)',
}}
>
{step.videoDurationLabel} - Demo
</Box>
)}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
justifyContent: 'space-between',
}}
>
<Button
onClick={onShowMe}
disabled={running}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 600,
fontSize: 13,
px: 1.4,
py: 0.55,
borderRadius: `${c.radius.md}px`,
boxShadow: `0 4px 12px ${c.accent.primary}40`,
'&:hover': { bgcolor: c.accent.hover ?? c.accent.primary },
'&.Mui-disabled': { opacity: 0.6, color: '#fff' },
display: 'flex',
alignItems: 'center',
gap: 0.7,
}}
>
Show me
<Box
component="span"
ref={cursorIconRef}
data-onboarding="show-me-cursor-icon"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CursorIconSmall color="#fff" />
</Box>
</Button>
<ButtonBase
onClick={onOpenRoadmap}
sx={{
fontSize: 12.5,
fontWeight: 500,
color: c.text.secondary,
'&:hover': { color: c.text.primary },
}}
>
See all todos
</ButtonBase>
<IconButton
size="small"
ref={infoBtnRef}
onClick={onToggleInfo}
sx={{ color: c.text.tertiary, p: 0.4 }}
aria-label="More info"
>
<HelpOutlineIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
interface CelebrationProps {
step: NonNullable<ReturnType<typeof findStepById>>;
accent: string;
}
const CelebrationView: React.FC<CelebrationProps> = ({ step, accent }) => {
const c = useClaudeTokens();
return (
<Box sx={{ px: 1.6, pt: 1.6, pb: 1.6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.8 }}>
<motion.div
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 240, damping: 16 }}
style={{ display: 'flex' }}
>
<CheckCircleIcon sx={{ fontSize: 22, color: accent }} />
</motion.div>
<Typography
sx={{
fontSize: 11.5,
fontWeight: 700,
letterSpacing: '0.06em',
color: accent,
textTransform: 'uppercase',
}}
>
Done
</Typography>
</Box>
<Box sx={{ position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<Typography
sx={{
fontSize: 16,
fontWeight: 600,
color: c.text.primary,
fontFamily: '"Charter", Georgia, serif',
position: 'relative',
display: 'inline-block',
}}
>
{step.title}
<motion.span
initial={{ width: 0 }}
animate={{ width: '100%' }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
style={{
position: 'absolute',
left: 0,
top: '52%',
height: 2,
background: accent,
transformOrigin: 'left center',
}}
/>
</Typography>
</Box>
<Typography
sx={{
mt: 1,
fontSize: 12,
color: c.text.muted,
lineHeight: 1.4,
}}
>
Loading next step
</Typography>
</Box>
);
};
const AllDoneView: React.FC<{ accent: string; tokens: ReturnType<typeof useClaudeTokens> }> = ({
accent,
tokens: c,
}) => (
<Box sx={{ px: 1.6, pt: 2, pb: 2, textAlign: 'center' }}>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 14 }}
style={{ display: 'inline-flex', justifyContent: 'center' }}
>
<CheckCircleIcon sx={{ fontSize: 40, color: accent }} />
</motion.div>
<Typography
sx={{
mt: 1.2,
fontSize: 16,
fontWeight: 600,
color: c.text.primary,
fontFamily: '"Charter", Georgia, serif',
}}
>
You're all set up
</Typography>
<Typography sx={{ mt: 0.4, fontSize: 12.5, color: c.text.secondary }}>
You've finished the OpenSwarm tour. You can re-run it anytime from Settings General.
</Typography>
</Box>
);
interface InfoPopoverProps {
stepId: string;
anchorRef: React.MutableRefObject<HTMLButtonElement | null>;
onClose: () => void;
tokens: ReturnType<typeof useClaudeTokens>;
}
const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, tokens: c }) => {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
const calc = () => {
const r = anchorRef.current?.getBoundingClientRect();
if (!r) return;
const POPOVER_W = 280;
const POPOVER_H = 240;
// Anchor below-and-to-the-left of the info button so the popover
// sits to the LEFT of the panel — matches figma image #66.
const top = Math.min(r.bottom + 8, window.innerHeight - POPOVER_H - 8);
const left = Math.max(8, r.right - POPOVER_W);
setPos({ top, left });
};
calc();
window.addEventListener('resize', calc);
return () => window.removeEventListener('resize', calc);
}, [anchorRef]);
// Click-away listener.
useEffect(() => {
const handler = (e: MouseEvent) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t)) return;
// If click landed inside the popover, leave it open.
const pop = document.getElementById('onboarding-info-popover');
if (pop?.contains(t)) return;
onClose();
};
window.addEventListener('mousedown', handler);
return () => window.removeEventListener('mousedown', handler);
}, [anchorRef, onClose]);
if (!pos) return null;
const text = INFO_BY_STEP_ID[stepId] ?? 'More information coming soon.';
return (
<motion.div
id="onboarding-info-popover"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
zIndex: 1250,
width: 280,
}}
>
<Box
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.md}px`,
boxShadow: '0 14px 40px rgba(0,0,0,0.18)',
p: 1.6,
fontFamily: c.font.sans,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.8 }}>
<HelpOutlineIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography
sx={{
fontSize: 11,
fontWeight: 700,
color: c.text.muted,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
More info
</Typography>
</Box>
<Typography
sx={{
fontSize: 11.8,
color: c.text.secondary,
lineHeight: 1.55,
whiteSpace: 'pre-line',
}}
>
{text}
</Typography>
</Box>
</motion.div>
);
};
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.
If you already have a subscription to ChatGPT, Claude, or Gemini, you can plug those directly into Open Swarm.
We also offer an Open Swarm subscription that gives you the same usage as these model providers.
Optionally you can choose to instead use API Keys directly.`,
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 action in Open Swarm has a permission policy that decides if an agent can use it and whether it requires your permission.
The Actions page is where you configure which actions are available, how they're authenticated, and what permissions they require.`,
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.
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.
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.
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.
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.
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.
When a task gets too complicated for a single agent, it has the ability to spawn its own sub-agents as it deems fit.
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.
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.
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.
After making an App, you can open it in your canvas alongside agents and browsers.`,
};
export default OnboardingPanel;
@@ -0,0 +1,199 @@
// Redux slice mirroring the persisted onboarding-v2 state. A thin
// subscriber in OnboardingRoot writes back to localStorage on change
// (debounced 200ms) so the in-memory state is the source of truth at
// runtime and disk is just for resume-after-restart.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const STORAGE_KEY = 'openswarm.onboarding.v2';
const SCHEMA_VERSION = 2 as const;
export type PanelMode = 'pill' | 'expanded' | 'roadmap' | 'hidden';
export interface PerStepState {
lastViewedAt: number;
videoWatched?: boolean;
// For multi-choice steps: which option the user picked (used for branching
// and analytics).
multiChoiceAnswers?: Record<string, string>;
}
export interface OnboardingProgressState {
version: typeof SCHEMA_VERSION;
startedAt: number;
completedSteps: string[];
currentStepId: string | null;
panelMode: PanelMode;
dismissedAt: number | null;
perStepState: Record<string, PerStepState>;
// Runtime-only — not persisted. True while AC is actively executing a
// step's ops. The panel hides chrome and the user can't open the roadmap
// mid-flow without first cancelling.
running: boolean;
// Set on first launch detection so we don't re-init from defaults on
// every mount.
initialized: boolean;
// Set briefly when a step completes so the panel can render a one-time
// strike-through + celebration animation before transitioning to the
// next step. Cleared by clearJustCompleted (the panel calls this from
// a 1500ms timeout after the animation plays).
justCompletedStepId: string | null;
// True after the user explicitly restarts the tour from Settings.
// Suppresses skipIf-based auto-marking for the rest of this tour run
// so the user gets a true fresh experience even if their prior data
// (existing skills, sessions, configured tools) would otherwise
// satisfy the predicates. False during normal first-launch detection
// so legitimately upgrading v1.0.29 users still see their already-
// configured pieces correctly pre-marked.
disableSkipIf: boolean;
}
export function loadFromStorage(): OnboardingProgressState | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<OnboardingProgressState>;
if (parsed.version !== SCHEMA_VERSION) return null;
return {
version: SCHEMA_VERSION,
startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : Date.now(),
completedSteps: Array.isArray(parsed.completedSteps) ? parsed.completedSteps : [],
currentStepId: typeof parsed.currentStepId === 'string' ? parsed.currentStepId : null,
panelMode: parsed.panelMode ?? 'pill',
dismissedAt: typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : null,
perStepState: (parsed.perStepState as Record<string, PerStepState>) ?? {},
running: false,
initialized: true,
justCompletedStepId: null,
disableSkipIf: Boolean((parsed as any).disableSkipIf),
};
} catch {
return null;
}
}
export function persistToStorage(state: OnboardingProgressState): void {
try {
const { running: _r, initialized: _i, ...persisted } = state;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted));
} catch {
/* localStorage unavailable */
}
}
const initialState: OnboardingProgressState = {
version: SCHEMA_VERSION,
startedAt: 0,
completedSteps: [],
currentStepId: null,
panelMode: 'pill',
dismissedAt: null,
perStepState: {},
running: false,
initialized: false,
justCompletedStepId: null,
disableSkipIf: false,
};
const slice = createSlice({
name: 'onboardingProgress',
initialState,
reducers: {
init(
state,
action: PayloadAction<{
currentStepId: string | null;
preCompleted: string[];
disableSkipIf?: boolean;
}>,
) {
if (state.initialized) return;
state.version = SCHEMA_VERSION;
state.startedAt = Date.now();
state.completedSteps = action.payload.preCompleted;
state.currentStepId = action.payload.currentStepId;
state.panelMode = 'pill';
state.dismissedAt = null;
state.perStepState = {};
state.running = false;
state.initialized = true;
state.disableSkipIf = Boolean(action.payload.disableSkipIf);
},
hydrate(state, action: PayloadAction<OnboardingProgressState>) {
// Replace from localStorage on launch.
Object.assign(state, action.payload, { running: false, initialized: true });
},
setPanelMode(state, action: PayloadAction<PanelMode>) {
state.panelMode = action.payload;
if (action.payload === 'hidden') {
state.dismissedAt = Date.now();
} else {
state.dismissedAt = null;
}
},
setCurrentStep(state, action: PayloadAction<string | null>) {
state.currentStepId = action.payload;
if (action.payload) {
const ps = state.perStepState[action.payload] ?? { lastViewedAt: 0 };
ps.lastViewedAt = Date.now();
state.perStepState[action.payload] = ps;
}
},
markStepCompleted(state, action: PayloadAction<string>) {
if (!state.completedSteps.includes(action.payload)) {
state.completedSteps.push(action.payload);
// Trigger the celebration / strike-through animation. The panel
// listens for this and clears it ~1.5s later via clearJustCompleted.
state.justCompletedStepId = action.payload;
}
},
clearJustCompleted(state) {
state.justCompletedStepId = null;
},
unmarkStepCompleted(state, action: PayloadAction<string>) {
state.completedSteps = state.completedSteps.filter((id) => id !== action.payload);
},
setRunning(state, action: PayloadAction<boolean>) {
state.running = action.payload;
},
recordMultiChoice(
state,
action: PayloadAction<{ stepId: string; opId: string; answerId: string }>,
) {
const { stepId, opId, answerId } = action.payload;
const ps = state.perStepState[stepId] ?? { lastViewedAt: Date.now() };
ps.multiChoiceAnswers = { ...(ps.multiChoiceAnswers ?? {}), [opId]: answerId };
state.perStepState[stepId] = ps;
},
resetTour(state) {
state.completedSteps = [];
state.currentStepId = null;
state.panelMode = 'expanded';
state.dismissedAt = null;
state.perStepState = {};
state.running = false;
state.startedAt = Date.now();
// Tour was explicitly restarted — give the user a true fresh
// experience by suppressing skipIf for the rest of this run.
// Otherwise residual data (existing skills installed during a
// prior tour, leftover seed-orchestration-demo agents, etc)
// would auto-mark steps complete the moment Redux state ticks.
state.disableSkipIf = true;
},
},
});
export const {
init,
hydrate,
setPanelMode,
setCurrentStep,
markStepCompleted,
clearJustCompleted,
unmarkStepCompleted,
setRunning,
recordMultiChoice,
resetTour,
} = slice.actions;
export default slice.reducer;
@@ -0,0 +1,274 @@
// Full 10-step roadmap. Modal opens from the panel's "See all todos" link.
// Stages cascade: Stage 2 unlocks once Stage 1 is fully complete.
import React from 'react';
import { Modal, Box, Typography, IconButton, Button } from '@mui/material';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import LockIcon from '@mui/icons-material/Lock';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useOnboardingProgress } from './hooks/useOnboardingProgress';
import { STAGE_GROUPS, STEPS, findStepById } from './steps';
import { STAGE_LABELS } from './steps/types';
import { onboardingDirector } from './OnboardingDirector';
import { report } from './telemetry';
const OnboardingRoadmapModal: React.FC = () => {
const c = useClaudeTokens();
const progress = useOnboardingProgress();
const open = progress.panelMode === 'roadmap';
const close = () => progress.setPanelMode('expanded');
const stage1Done = STAGE_GROUPS[0].steps.every((s) =>
progress.completedSteps.includes(s.id),
);
const currentStep = progress.currentStepId
? findStepById(progress.currentStepId)
: STEPS.find((s) => !progress.completedSteps.includes(s.id));
const totalDone = progress.completedSteps.length;
const total = STEPS.length;
const jumpToCurrent = () => {
if (currentStep) progress.setCurrentStep(currentStep.id);
progress.setPanelMode('expanded');
};
return (
<Modal
open={open}
onClose={close}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
slotProps={{ backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.45)' } } }}
>
<Box
sx={{
width: '100%',
maxWidth: 460,
mx: 2,
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: '0 16px 48px rgba(0,0,0,0.30)',
outline: 'none',
overflow: 'hidden',
fontFamily: c.font.sans,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.4,
pt: 1.8,
pb: 1.2,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
<Box>
<Typography
sx={{
fontSize: 16,
fontWeight: 600,
fontFamily: '"Charter", Georgia, serif',
}}
>
Your roadmap
</Typography>
<Typography sx={{ fontSize: 12, color: c.text.muted, mt: 0.2 }}>
{totalDone}/{total} milestones reached
</Typography>
</Box>
<IconButton
size="small"
onClick={close}
sx={{ color: c.text.tertiary }}
aria-label="Close roadmap"
>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
{/* Stages */}
<Box sx={{ px: 2.4, pt: 1.6, pb: 0.5 }}>
{STAGE_GROUPS.map((group, gi) => {
const stageDone = group.steps.filter((s) =>
progress.completedSteps.includes(s.id),
).length;
const isLocked = gi === 1 && !stage1Done;
const isInProgress = !isLocked && stageDone < group.steps.length;
const stageLabel = isLocked
? 'LOCKED'
: isInProgress
? 'IN PROGRESS'
: 'COMPLETE';
return (
<Box key={group.stage} sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography
sx={{
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.08em',
color: isLocked
? c.text.tertiary
: isInProgress
? c.accent.primary
: c.text.secondary,
}}
>
STAGE {gi + 1} · {stageLabel}
</Typography>
</Box>
<Typography sx={{ fontSize: 11, color: c.text.muted }}>
{stageDone}/{group.steps.length}
</Typography>
</Box>
<Typography
sx={{
fontSize: 14,
fontWeight: 600,
mb: 0.8,
color: isLocked ? c.text.tertiary : c.text.primary,
}}
>
{STAGE_LABELS[group.stage]}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
{group.steps.map((step) => {
const isDone = progress.completedSteps.includes(step.id);
const isCurrent = currentStep?.id === step.id && !isDone;
return (
<Box
key={step.id}
onClick={() => {
if (isLocked) return;
// If a step is mid-flow, abort it before
// jumping. Otherwise the AC keeps animating
// for a step the user no longer sees.
if (progress.running) {
onboardingDirector.cancelStep();
}
report('roadmap_step_clicked', {
step_id: step.id,
from_step_id: progress.currentStepId,
});
progress.setCurrentStep(step.id);
progress.setPanelMode('expanded');
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
py: 0.45,
px: 0.4,
borderRadius: `${c.radius.sm}px`,
cursor: isLocked ? 'default' : 'pointer',
opacity: isLocked ? 0.55 : 1,
transition: 'background 0.12s',
'&:hover': isLocked
? {}
: { bgcolor: c.bg.secondary },
}}
>
{isLocked ? (
<LockIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
) : isDone ? (
<CheckCircleIcon
sx={{ fontSize: 17, color: c.accent.primary }}
/>
) : (
<RadioButtonUncheckedIcon
sx={{
fontSize: 17,
color: isCurrent
? c.accent.primary
: c.border.medium,
}}
/>
)}
<Typography
sx={{
fontSize: 13,
fontWeight: isCurrent ? 600 : 500,
color: isDone
? c.text.tertiary
: c.text.primary,
textDecoration: isDone ? 'line-through' : 'none',
flexGrow: 1,
}}
>
{step.title}
</Typography>
{isCurrent && (
<Typography
sx={{
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.05em',
color: c.accent.primary,
textTransform: 'uppercase',
}}
>
current
</Typography>
)}
</Box>
);
})}
</Box>
</Box>
);
})}
</Box>
{/* Footer */}
<Box
sx={{
px: 2.4,
pb: 2,
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Button
onClick={jumpToCurrent}
disabled={!currentStep}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 600,
fontSize: 13,
px: 1.6,
py: 0.6,
borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: c.accent.hover ?? c.accent.primary },
}}
>
Jump to current todo
</Button>
</Box>
</Box>
</Modal>
);
};
export default OnboardingRoadmapModal;
@@ -0,0 +1,251 @@
// Top-level mount for the onboarding-v2 system. Hydrates persisted state,
// attaches the Director, mounts the Panel + AC.
import React, { useEffect, useRef } from 'react';
import { useStore } from 'react-redux';
import type { Store } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { RootState } from '@/shared/state/store';
import {
hydrate,
init,
loadFromStorage,
persistToStorage,
markStepCompleted,
} from './OnboardingProgressSlice';
import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor';
import { onboardingDirector } from './OnboardingDirector';
import { STEPS } from './steps';
import OnboardingPanel from './OnboardingPanel';
import { onboardingBus } from './eventBus';
import { report } from './telemetry';
const PERSIST_DEBOUNCE_MS = 200;
const OnboardingRoot: React.FC = () => {
const acRef = useRef<AgenticCursorHandle | null>(null);
const dispatch = useAppDispatch();
const store = useStore<RootState>() as Store<RootState>;
const tokens = useClaudeTokens();
const progress = useAppSelector((s) => s.onboardingProgress);
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
// Hydrate from localStorage on first mount, or initialize fresh state.
useEffect(() => {
if (progress.initialized) return;
if (!settingsLoaded) return;
const persisted = loadFromStorage();
if (persisted) {
dispatch(hydrate(persisted));
return;
}
// Always start with no pre-completed steps. The legitimate "v1.0.29
// user has a model already configured" case is now handled by the
// user simply walking through step 1 — the skipIf predicates still
// exist but they fire only via the live subscriber's baseline-aware
// path, which gates them behind real user action. Pre-marking at
// init time was unreliable: backend fetches land async, and at
// mount time we either don't have data yet (so nothing to mark)
// or we have it via stale Redux from a previous run (so we
// wrongly mark the wrong things). Net: simpler + always-fresh.
dispatch(
init({
currentStepId: STEPS[0]?.id ?? null,
preCompleted: [],
disableSkipIf: false,
}),
);
}, [progress.initialized, settingsLoaded, dispatch, store]);
// Watch for "user did the onboarding thing outside the flow" + bridge
// selected Redux signals to the event bus.
//
// Critical perf detail: the naive store.subscribe runs on EVERY dispatch
// (chat streaming = hundreds per second). The inner work — looping all
// STEPS, walking sessions, walking browserCards — is small individually
// but death-by-a-thousand-cuts over a long agent stream.
//
// Mitigation: collapse all dispatches in the same microtask into a
// single check via a `pending` flag + queueMicrotask. The state we
// care about (skipIf evaluations, card counts, session statuses) only
// matters at *commit* boundaries, never per-action — so coalescing
// dispatches is free.
useEffect(() => {
let last = new Set(progress.completedSteps);
let lastBrowserCount = Object.keys(
store.getState().dashboardLayout?.browserCards ?? {},
).length;
let lastSessionCount = Object.keys(
(store.getState() as any).agents?.sessions ?? {},
).length;
let lastOutputCount = Object.keys(
(store.getState() as any).outputs?.items ?? {},
).length;
// Baseline-snapshot of which skipIf predicates were ALREADY satisfied
// at startup. Any step whose predicate is in this set won't be
// auto-marked by the live subscriber — the user has to actually go
// through it (or do the equivalent thing during this run). This kills
// the "step 3 instantly marks done because backend fetchSessions
// landed" bug, where async data arriving post-mount caused predicates
// to flip false→true and the subscriber marked steps without any
// user interaction.
//
// The snapshot is captured on the first store-tick AFTER a small
// settle delay — enough for fetchSettings/Sessions/Skills/Outputs
// to all land. Anything true at that point counts as "pre-existing
// backend state" and is excluded from auto-marking for the rest
// of the run.
let baselinePredicateMet: Set<string> | null = null;
const baselineCaptureAt = Date.now() + 2000;
let lastStatuses: Record<string, string> = {};
const seedStatuses = () => {
const sessions = (store.getState() as any).agents?.sessions ?? {};
const out: Record<string, string> = {};
for (const [id, s] of Object.entries(sessions)) {
const st = (s as any)?.status;
if (typeof st === 'string') out[id] = st;
}
lastStatuses = out;
};
seedStatuses();
let pending = false;
const runCheck = () => {
pending = false;
const state = store.getState();
const suppressSkipIf = state.onboardingProgress?.disableSkipIf === true;
// Capture the baseline of pre-satisfied predicates after the
// initial fetch settle. This snapshot is sticky for the run.
if (baselinePredicateMet === null && Date.now() >= baselineCaptureAt) {
baselinePredicateMet = new Set();
for (const s of STEPS) {
if (s.skipIf?.(state)) baselinePredicateMet.add(s.id);
}
}
const allSkippablesDone = STEPS.every(
(s) => !s.skipIf || last.has(s.id),
);
// Skip the live evaluation entirely if (a) suppression is on,
// (b) baseline hasn't captured yet (we're still in the settle
// window — predicates would just see fetch-driven false→true
// flips that we want to ignore), or (c) every skippable step
// is already marked.
if (
!suppressSkipIf &&
!allSkippablesDone &&
baselinePredicateMet !== null
) {
for (const s of STEPS) {
if (last.has(s.id)) continue;
if (!s.skipIf) continue;
// Predicates that were ALREADY true at baseline are excluded —
// the only way to mark them complete now is via genuine user
// action (bus events fired from product code) or via the
// tour's outro path. Prevents fetched-from-backend data from
// leaking past the gate later in the run.
if (baselinePredicateMet.has(s.id)) continue;
if (s.skipIf(state)) {
last = new Set([...Array.from(last), s.id]);
dispatch(markStepCompleted(s.id));
report('step_skipped_via_skipif', { step_id: s.id, stage: s.stage });
}
}
}
const bc = Object.keys(state.dashboardLayout?.browserCards ?? {}).length;
if (bc > lastBrowserCount) {
onboardingBus.emit('browser:spawned');
}
lastBrowserCount = bc;
const sessions = (state as any).agents?.sessions ?? {};
const sc = Object.keys(sessions).length;
if (sc > lastSessionCount) {
onboardingBus.emit('agent:spawned');
}
lastSessionCount = sc;
const outputs = (state as any).outputs?.items ?? {};
const oc = Object.keys(outputs).length;
if (oc > lastOutputCount) {
onboardingBus.emit('app:generation_done');
}
lastOutputCount = oc;
let nextStatuses: Record<string, string> | null = null;
for (const [id, s] of Object.entries(sessions)) {
const status = (s as any)?.status;
if (typeof status !== 'string') continue;
const prev = lastStatuses[id];
if (prev !== status) {
if (status === 'completed' && prev !== 'completed') {
onboardingBus.emit('agent:completed');
}
nextStatuses ??= { ...lastStatuses };
nextStatuses[id] = status;
}
}
if (nextStatuses) lastStatuses = nextStatuses;
};
return store.subscribe(() => {
// Coalesce N dispatches in the same microtask into 1 check. Cheap
// boolean flag + queueMicrotask means the cost per dispatch is now
// a single property write, not a full state walk. The actual work
// still runs at most once per "tick" of state updates — which is
// all that matters for skipIf semantics.
if (pending) return;
pending = true;
queueMicrotask(runCheck);
});
}, [progress.completedSteps, dispatch, store]);
// Persist Redux progress → localStorage, debounced.
useEffect(() => {
if (!progress.initialized) return;
const t = window.setTimeout(() => {
persistToStorage(store.getState().onboardingProgress);
}, PERSIST_DEBOUNCE_MS);
return () => window.clearTimeout(t);
}, [progress, store]);
// Attach Director once the AC is mounted.
useEffect(() => {
onboardingDirector.attach({
acRef,
store,
getAccentColor: () => tokens.accent.primary,
isDependencySatisfied: (depId) => {
// Step 4's outcome is "a browser card currently exists on the canvas."
if (depId === 'use_browser') {
const cards = store.getState().dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
}
return false;
},
});
return () => onboardingDirector.detach();
}, [store, tokens.accent.primary]);
// Don't render the panel until we know whether the user is signed in. The
// panel sits on the dashboard, which only mounts post-sign-in anyway, but
// this guard keeps us out of the SignInGate's z-index space.
if (!settingsLoaded || !userId) return null;
if (!progress.initialized) return null;
return (
<>
<AgenticCursor ref={acRef} />
<OnboardingPanel />
</>
);
};
export default OnboardingRoot;
@@ -0,0 +1,163 @@
// Visual gesture helpers — drop a transient DOM node, animate it, clean up.
// These don't trigger any product code; they just render eye-candy that
// makes the cursor's "intent" legible (a click ripple, a drag-rect).
export function clickRipple(x: number, y: number, color: string): void {
const SIZE = 28;
const el = document.createElement('div');
el.style.cssText = [
'position: fixed',
`left: ${x - SIZE / 2}px`,
`top: ${y - SIZE / 2}px`,
`width: ${SIZE}px`,
`height: ${SIZE}px`,
'border-radius: 50%',
`background: ${color}`,
'pointer-events: none',
'z-index: 10499',
'opacity: 0.55',
'transform: scale(0.4)',
'transition: transform 0.45s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.45s linear',
].join(';');
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.transform = 'scale(3)';
el.style.opacity = '0';
});
window.setTimeout(() => el.remove(), 600);
}
export interface DragRect {
fromX: number;
fromY: number;
toX: number;
toY: number;
}
export function animateDragSelect(rect: DragRect, color: string, durationMs = 600): Promise<void> {
return new Promise((resolve) => {
const el = document.createElement('div');
const left = Math.min(rect.fromX, rect.toX);
const top = Math.min(rect.fromY, rect.toY);
el.style.cssText = [
'position: fixed',
`left: ${rect.fromX}px`,
`top: ${rect.fromY}px`,
'width: 0px',
'height: 0px',
`border: 1.5px dashed ${color}`,
`background: ${color}1a`, // ~10% alpha
'pointer-events: none',
'z-index: 10499',
'border-radius: 4px',
`transition: all ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`,
].join(';');
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${Math.abs(rect.toX - rect.fromX)}px`;
el.style.height = `${Math.abs(rect.toY - rect.fromY)}px`;
});
window.setTimeout(() => {
el.style.opacity = '0';
}, durationMs + 200);
window.setTimeout(() => {
el.remove();
resolve();
}, durationMs + 600);
});
}
// Soft glow rect overlaid on a target element. Used by highlight_section to
// draw the user's eye to a region (e.g. settings-pro-section) without
// taking a click. Caller is responsible for calling the returned cleanup.
export function spawnGlowRect(target: HTMLElement, color: string): () => void {
const rect = target.getBoundingClientRect();
const pad = 6;
const el = document.createElement('div');
el.style.cssText = [
'position: fixed',
`left: ${rect.left - pad}px`,
`top: ${rect.top - pad}px`,
`width: ${rect.width + pad * 2}px`,
`height: ${rect.height + pad * 2}px`,
'border-radius: 12px',
`border: 2px solid ${color}`,
`box-shadow: 0 0 24px ${color}55, inset 0 0 18px ${color}22`,
'pointer-events: none',
'z-index: 10498',
'opacity: 0',
'transition: opacity 0.3s ease',
].join(';');
document.body.appendChild(el);
requestAnimationFrame(() => {
el.style.opacity = '1';
});
return () => {
el.style.opacity = '0';
window.setTimeout(() => el.remove(), 320);
};
}
// Lighter "what AC is pointing at" highlight — softer than spawnGlowRect
// and follows the live rect each frame so the ring stays glued through
// reflows / scrolls. Used by the runtime alongside startTracking so the
// user can always see what AC is gesturing toward.
export function spawnLiveTargetGlow(
target: HTMLElement,
color: string,
): () => void {
const el = document.createElement('div');
el.style.cssText = [
'position: fixed',
'border-radius: 8px',
// Sleek mode: thin 1px border at ~40% opacity, no inset glow, just a
// soft outer halo. Reads as "this is what AC is pointing at" without
// looking like a marketing demo or selection indicator. Previous
// 1.5px @ 60% + inset glow was too heavy.
`border: 1px solid ${color}66`,
`box-shadow: 0 0 0 1px ${color}1f, 0 0 8px ${color}33`,
'pointer-events: none',
'z-index: 10497',
'opacity: 0',
'transition: opacity 0.2s ease',
'transform: translateZ(0)',
].join(';');
document.body.appendChild(el);
let cancelled = false;
let cachedTarget: HTMLElement | null = target;
const PAD = 4;
const update = () => {
if (cancelled) return;
if (!cachedTarget?.isConnected) {
// Target gone — fade out.
el.style.opacity = '0';
return;
}
const r = cachedTarget.getBoundingClientRect();
if (r.width === 0 && r.height === 0) {
el.style.opacity = '0';
} else {
el.style.left = `${r.left - PAD}px`;
el.style.top = `${r.top - PAD}px`;
el.style.width = `${r.width + PAD * 2}px`;
el.style.height = `${r.height + PAD * 2}px`;
if (el.style.opacity !== '1') el.style.opacity = '1';
}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
return () => {
cancelled = true;
el.style.opacity = '0';
window.setTimeout(() => el.remove(), 240);
};
}
// Wait helper used between ops. Avoids `setTimeout` everywhere.
export function sleep(ms: number): Promise<void> {
return new Promise((r) => window.setTimeout(r, ms));
}
@@ -0,0 +1,145 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { Box, Typography, ButtonBase } from '@mui/material';
import { motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCursorPosition } from './cursorStore';
import type { ACMultiChoiceOption } from '../steps/types';
interface Props {
question: string;
options: ACMultiChoiceOption[];
onAnswer: (id: string) => void;
offset?: { x: number; y: number };
}
const SAFE_PAD = 8;
const APPROX_W = 320;
const APPROX_H = 200;
/**
* Single-select multi-choice popup. Same chrome as ACPopup but with
* answer chips. Captures pointer events (auto) so chips are clickable.
* Stays mounted until user picks (or runtime aborts via hidePopup).
*/
const ACMultiChoice: React.FC<Props> = ({
question,
options,
onAnswer,
offset = { x: 14, y: 14 },
}) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ x: number; y: number }>({
x: x + offset.x,
y: y + offset.y,
});
useLayoutEffect(() => {
const el = ref.current;
const w = el?.offsetWidth ?? APPROX_W;
const h = el?.offsetHeight ?? APPROX_H;
const vw = window.innerWidth;
const vh = window.innerHeight;
let nx = x + offset.x;
let ny = y + offset.y;
if (nx + w + SAFE_PAD > vw) nx = x - w - offset.x;
if (ny + h + SAFE_PAD > vh) ny = y - h - offset.y;
nx = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
ny = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
setPos({ x: nx, y: ny });
}, [x, y, offset.x, offset.y, options.length, question]);
if (!visible) return null;
return (
<motion.div
key="ac-multichoice"
ref={ref}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1, x: pos.x, y: pos.y }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
opacity: { duration: 0.16 },
scale: { duration: 0.16 },
x: { type: 'spring', stiffness: 300, damping: 30 },
y: { type: 'spring', stiffness: 300, damping: 30 },
}}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 10501,
pointerEvents: 'auto',
}}
>
<Box
sx={{
maxWidth: 320,
minWidth: 240,
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
borderRadius: '14px',
boxShadow: '0 12px 32px rgba(0,0,0,0.25)',
px: 1.6,
py: 1.2,
fontFamily: c.font.sans,
}}
>
<Typography
sx={{
fontSize: '0.84rem',
fontWeight: 600,
color: c.text.primary,
lineHeight: 1.4,
mb: 1,
}}
>
{question}
</Typography>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}
role="radiogroup"
aria-label={question}
>
{options.map((opt) => (
<ButtonBase
key={opt.id}
onClick={() => onAnswer(opt.id)}
role="radio"
aria-checked={false}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
bgcolor: 'transparent',
color: c.text.primary,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
px: 1.1,
py: 0.7,
fontSize: '0.78rem',
fontWeight: 500,
fontFamily: c.font.sans,
transition: 'all 0.12s',
'&:hover': {
bgcolor: `${c.accent.primary}14`,
borderColor: c.accent.primary,
color: c.accent.primary,
},
'&:focus-visible': {
outline: `2px solid ${c.accent.primary}`,
outlineOffset: 2,
},
}}
>
{opt.label}
</ButtonBase>
))}
</Box>
</Box>
</motion.div>
);
};
export default ACMultiChoice;
@@ -0,0 +1,209 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Typography } from '@mui/material';
import { motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCursorPosition } from './cursorStore';
interface Props {
text: string;
/** Offset from cursor tip in px when there's room. */
offset?: { x: number; y: number };
}
const SAFE_PAD = 8;
// Slight bump to APPROX_W to match the larger font — keeps line-wrap
// behavior similar to before. The runtime measures the real rect via
// ref so this is just an initial-mount estimate.
const APPROX_W = 320;
const APPROX_H = 70;
// Pokémon-dialog cadence — letters pop in steadily, punctuation gets
// a small extra pause so sentences "land" instead of slurring together.
const STREAM_MS_PER_CHAR = 20;
const STREAM_PUNCT_EXTRA_MS = 140; // after . , ! ? ; :
const STREAM_MIN_CHARS = 5;
/**
* Tiny popup that follows the cursor. Non-blocking no CTA.
*
* Streams text character-by-character like an RPG dialog box (modulo
* very short strings, which appear instantly to avoid visual jank on
* single-word popups).
*
* Positioning: prefers bottom-right of the cursor, but flips quadrants
* when the chosen position would clip past the viewport. Re-evaluates
* whenever the cursor moves (cursorStore subscription).
*/
const ACPopup: React.FC<Props> = ({ text, offset = { x: 14, y: 14 } }) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ x: number; y: number; flipX: boolean; flipY: boolean }>({
x: x + offset.x,
y: y + offset.y,
flipX: false,
flipY: false,
});
// Streaming text state — grows from 0 to text.length char-by-char.
// 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."
const [streamCount, setStreamCount] = useState<number>(
text.length < STREAM_MIN_CHARS ? text.length : 0,
);
useEffect(() => {
if (text.length < STREAM_MIN_CHARS) {
setStreamCount(text.length);
return;
}
setStreamCount(0);
let i = 0;
let timer: number | null = null;
const tick = () => {
i += 1;
setStreamCount(i);
if (i >= text.length) {
timer = null;
return;
}
// Look at the char we *just* revealed — if it's punctuation,
// wait an extra beat before the next one. Mirrors Pokémon's
// "..." and end-of-sentence pacing.
const justShown = text[i - 1];
const isPunct = /[.,!?;:]/.test(justShown);
const delay = STREAM_MS_PER_CHAR + (isPunct ? STREAM_PUNCT_EXTRA_MS : 0);
timer = window.setTimeout(tick, delay);
};
timer = window.setTimeout(tick, STREAM_MS_PER_CHAR);
return () => {
if (timer !== null) window.clearTimeout(timer);
};
}, [text]);
useLayoutEffect(() => {
const el = ref.current;
const w = el?.offsetWidth ?? APPROX_W;
const h = el?.offsetHeight ?? APPROX_H;
const vw = window.innerWidth;
const vh = window.innerHeight;
let nx = x + offset.x;
let ny = y + offset.y;
let flipX = false;
let flipY = false;
if (nx + w + SAFE_PAD > vw) {
nx = x - w - offset.x;
flipX = true;
}
if (ny + h + SAFE_PAD > vh) {
ny = y - h - offset.y;
flipY = true;
}
nx = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
ny = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
setPos({ x: nx, y: ny, flipX, flipY });
}, [x, y, offset.x, offset.y, text, streamCount]);
if (!visible) return null;
const displayText = text.slice(0, streamCount);
// Reserve full width with invisible char to prevent the bubble from
// jiggling as letters arrive — invisible character keeps wrap consistent.
const isStreaming = streamCount < text.length;
return (
<motion.div
key="ac-popup"
ref={ref}
initial={{ opacity: 0, scale: 0.85 }}
animate={{
opacity: 1,
scale: 1,
x: pos.x,
y: pos.y,
}}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
opacity: { duration: 0.14 },
scale: { duration: 0.14 },
x: { type: 'spring', stiffness: 320, damping: 32 },
y: { type: 'spring', stiffness: 320, damping: 32 },
}}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 10501,
pointerEvents: 'none',
}}
>
<Box
sx={{
position: 'relative',
maxWidth: 320,
minWidth: 110,
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.accent.primary}`,
borderRadius: '14px',
boxShadow: `0 14px 36px rgba(0,0,0,0.32), 0 0 16px ${c.accent.primary}33`,
px: 1.6,
py: 1.0,
fontFamily: c.font.sans,
}}
>
{/* Tail pointing back at the cursor. */}
<Box
sx={{
position: 'absolute',
width: 10,
height: 10,
bgcolor: c.bg.surface,
border: `1px solid ${c.accent.primary}`,
transform: 'rotate(45deg)',
top: pos.flipY ? 'auto' : -5,
bottom: pos.flipY ? -5 : 'auto',
left: pos.flipX ? 'auto' : 14,
right: pos.flipX ? 14 : 'auto',
borderRight: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderBottom: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderTop: pos.flipY ? 'none' : `1px solid ${c.accent.primary}`,
borderLeft: pos.flipY ? 'none' : `1px solid ${c.accent.primary}`,
}}
/>
<Typography
sx={{
// Sized to feel like a Pokémon dialog — small but firm.
// 0.85rem reads cleanly without dominating the screen,
// and pairs with the bolder weight to stay legible.
fontSize: '0.85rem',
color: c.text.primary,
fontWeight: 600,
lineHeight: 1.4,
whiteSpace: 'pre-line',
position: 'relative',
}}
>
{displayText}
{isStreaming && (
<Box
component="span"
sx={{
opacity: 0,
pointerEvents: 'none',
userSelect: 'none',
}}
>
{text.slice(streamCount)}
</Box>
)}
</Typography>
</Box>
</motion.div>
);
};
export default ACPopup;
@@ -0,0 +1,119 @@
// Type a string into a target input or contentEditable element one character
// at a time, dispatching events that React's reconciler observes so the
// product's controlled input state stays in sync.
//
// React intercepts native value setters on <input>/<textarea> via a
// prototype-level descriptor, then dispatches 'input' events to its own
// synthetic event system. To make a fake change visible to React, we
// have to invoke the native setter via the prototype descriptor and then
// dispatch a real 'input' event. Setting `el.value = ...` directly is
// silently ignored by React's onChange.
const INPUT_PROTO_VALUE_DESC =
typeof window !== 'undefined'
? Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
)
: undefined;
const TEXTAREA_PROTO_VALUE_DESC =
typeof window !== 'undefined'
? Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value',
)
: undefined;
function nativeSetValue(el: HTMLElement, value: string): void {
if (el instanceof HTMLInputElement && INPUT_PROTO_VALUE_DESC?.set) {
INPUT_PROTO_VALUE_DESC.set.call(el, value);
} else if (
el instanceof HTMLTextAreaElement &&
TEXTAREA_PROTO_VALUE_DESC?.set
) {
TEXTAREA_PROTO_VALUE_DESC.set.call(el, value);
} else {
(el as HTMLInputElement).value = value;
}
}
function dispatchInput(el: HTMLElement): void {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// 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.
function insertContentEditableText(el: HTMLElement, ch: string): void {
el.focus();
// Place caret at end so insertion appends rather than overwrites.
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
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' }));
}
}
export interface TypeIntoOptions {
speedMs?: number;
// Optional callback fired after each character — lets the cursor
// re-align to the input's right edge as text grows.
onTick?: () => void;
}
export async function typeInto(
el: HTMLElement,
text: string,
opts: TypeIntoOptions = {},
): Promise<void> {
// Default char-cadence — faster than the original 40ms (which felt
// like watching molasses for long URLs). 18ms is still slow enough to
// read live but doesn't make typing the main bottleneck of the step.
const speed = opts.speedMs ?? 18;
el.focus();
// 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.
if (el.isContentEditable) {
for (const ch of text) {
insertContentEditableText(el, ch);
opts.onTick?.();
await new Promise((r) => window.setTimeout(r, speed));
}
return;
}
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));
}
}
@@ -0,0 +1,378 @@
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { motion, useAnimationControls, AnimatePresence } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cursorStore } from './cursorStore';
import { resolveSelector } from '../selectors';
import ACPopup from './ACPopup';
import ACMultiChoice from './ACMultiChoice';
import type { ACMultiChoiceOption } from '../steps/types';
export interface AgenticCursorHandle {
fadeIn: (from: { x: number; y: number }) => Promise<void>;
fadeOut: (to: { x: number; y: number }) => Promise<void>;
moveTo: (x: number, y: number) => Promise<void>;
pressClick: () => Promise<void>;
/**
* Lock the cursor to a live data-onboarding selector. After this is
* called the cursor re-resolves the selector and re-reads its rect on
* every animation frame, pinning itself (and any attached popup) to
* the element's current center. Survives reflows, scrolls, sidebar
* collapses, and React node swaps (uninstalled-card installed-card,
* etc.) the cursor follows the live target instead of stranding
* itself at the rect we read at the time of move_to.
*
* Pass an offset to override the default (center-of-rect). Calling
* startTracking again replaces any prior tracker; the next op that
* physically moves the cursor (move_to / click / type_into /
* drag_select / outro) calls stopTracking automatically.
*/
startTracking: (selector: string, offset?: { x: number; y: number }) => void;
stopTracking: () => void;
/**
* Show a non-blocking popup next to the cursor. Returns immediately;
* the popup stays visible until hidePopup() is called or another
* showPopup replaces it. The runtime calls hidePopup() before any op
* that physically moves the cursor or types, so the popup naturally
* disappears when the cursor's "instruction" no longer applies.
*/
showPopup: (text: string) => void;
/**
* Single-select multi-choice. Resolves with the chosen option id; the
* panel that calls this can route the rest of the flow accordingly.
*/
showMultiChoice: (q: string, opts: ACMultiChoiceOption[]) => Promise<string>;
hidePopup: () => void;
getPosition: () => { x: number; y: number };
}
interface PopupState {
text: string;
}
interface MultiChoiceState {
question: string;
options: ACMultiChoiceOption[];
resolve: (id: string) => void;
}
// Tighter spring than the original (180/22) — settles ~30% faster while
// keeping the soft "alive" arrival, so the cursor feels responsive
// instead of slow-zooming across the screen for every move op.
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const c = useClaudeTokens();
const controls = useAnimationControls();
const posRef = useRef({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
const [popup, setPopup] = useState<PopupState | null>(null);
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
// Active sticky-tracker handle. Set by startTracking, cleared by
// stopTracking. Survives renders via ref so the rAF loop can be
// cancelled cleanly even if the component re-renders mid-flight.
const trackerRef = useRef<{ stop: () => void } | null>(null);
// Mirror the cursor's logical position into the cursorStore so popups
// can follow without re-running through Framer's animation pipeline.
const writePos = (x: number, y: number, vis = true) => {
posRef.current = { x, y };
cursorStore.set({ x, y, visible: vis });
};
// Stop any sticky tracker. Idempotent.
const stopTrackingInternal = () => {
if (trackerRef.current) {
trackerRef.current.stop();
trackerRef.current = null;
}
};
// Defensive: if the AC unmounts mid-flow (Director.detach, panel
// hidden), the rAF callback would otherwise keep firing and pinning a
// dead component's `controls` to the live target every frame. The
// unmount cleanup cancels it.
useEffect(() => {
return () => stopTrackingInternal();
}, []);
useImperativeHandle(ref, () => ({
async fadeIn(from) {
stopTrackingInternal();
writePos(from.x, from.y, true);
controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.5 });
setVisible(true);
await controls.start({
opacity: 1,
scale: 1,
transition: { duration: 0.32, ease: 'easeOut' },
});
},
async moveTo(x, y) {
// moveTo is for animated jumps to a fixed coord. Stop any prior
// tracker first so it doesn't keep snapping the cursor back to its
// old anchor mid-animation. The runtime calls startTracking after
// the await resolves, re-pinning to the live target.
stopTrackingInternal();
await controls.start({
x,
y,
transition: SPRING,
});
writePos(x, y, true);
},
async fadeOut(to) {
stopTrackingInternal();
await controls.start({ x: to.x, y: to.y, transition: SPRING });
writePos(to.x, to.y, true);
await controls.start({
opacity: 0,
scale: 0.5,
transition: { duration: 0.28, ease: 'easeIn' },
});
cursorStore.set({ visible: false });
setVisible(false);
},
async pressClick() {
await controls.start({ scale: 0.78, transition: { duration: 0.08 } });
await controls.start({ scale: 1, transition: { duration: 0.14 } });
},
startTracking(selector, offset) {
stopTrackingInternal();
const offX = offset?.x ?? 0;
const offY = offset?.y ?? 0;
let cancelled = false;
let rafId = 0;
// Cache the resolved node by reference. Re-querying every frame
// would make the cursor flicker between transient duplicate matches
// when React re-renders (e.g. Reddit Card hover state, Switch
// animation, install-toggle transition). Holding the node stable
// means the cursor follows the SAME element through reflows; we
// only re-query when the cached node leaves the document.
let cachedEl: HTMLElement | null = resolveSelector(selector);
let lastX = posRef.current.x;
let lastY = posRef.current.y;
// Lost-target tracking. If the cached element disconnects (user
// navigates away, collapses the section, etc) and we can't re-find
// it for >LOST_TIMEOUT_MS, fire the lost-target event so the
// runtime can outro gracefully and offer a recovery hint.
let lostSinceMs: number | null = null;
const LOST_TIMEOUT_MS = 2500;
const EPSILON = 0.5;
// Drop frames where the resolved rect would teleport the cursor by
// more than this. Real reflows move elements a few px per frame;
// 600px instantly is a sign of a stale/transient rect mid-commit.
const MAX_JUMP_PX = 600;
// Title-bar drag region (38px in AppShell). Pinning the cursor
// there lands it on the macOS traffic lights / Electron drag-area
// — never an intentional onboarding target. Skip those frames.
const TITLE_BAR_BOTTOM = 38;
const tick = () => {
if (cancelled) return;
if (!cachedEl || !cachedEl.isConnected) {
cachedEl = resolveSelector(selector);
if (!cachedEl) {
// Element vanished. Start (or continue) the lost-target
// countdown — once we exceed the timeout, signal the
// runtime to abort.
const now = Date.now();
if (lostSinceMs === null) lostSinceMs = now;
if (now - lostSinceMs > LOST_TIMEOUT_MS) {
cancelled = true;
cancelAnimationFrame(rafId);
// Custom event the runtime listens for. Decoupled from
// controls/Promise machinery so we can fire from inside
// a rAF tick without races.
window.dispatchEvent(
new CustomEvent('openswarm:onboarding:lost_target', {
detail: { selector },
}),
);
return;
}
} else {
// Re-acquired — clear the countdown.
lostSinceMs = null;
}
} else {
lostSinceMs = null;
}
if (cachedEl) {
const r = cachedEl.getBoundingClientRect();
if (r.width > 0 || r.height > 0) {
const cx = r.left + r.width / 2 + offX;
const cy = r.top + r.height / 2 + offY;
// Viewport guards: skip frames where pinning would land the
// cursor outside the visible window OR inside the title-bar
// drag region. These don't help the user — they're symptoms
// of a stale read or a hidden/overflowed target — and the
// next legitimate frame will pin correctly.
const offWindow =
cx < 0 ||
cy < 0 ||
cx > window.innerWidth ||
cy > window.innerHeight;
const inTitleBar = cy < TITLE_BAR_BOTTOM;
if (!offWindow && !inTitleBar) {
const dx = Math.abs(cx - lastX);
const dy = Math.abs(cy - lastY);
const teleport = dx > MAX_JUMP_PX || dy > MAX_JUMP_PX;
if (!teleport && (dx > EPSILON || dy > EPSILON)) {
controls.set({ x: cx, y: cy });
writePos(cx, cy, true);
lastX = cx;
lastY = cy;
}
}
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
trackerRef.current = {
stop: () => {
cancelled = true;
cancelAnimationFrame(rafId);
},
};
},
stopTracking() {
stopTrackingInternal();
},
showPopup(text) {
// Non-blocking — replaces any existing popup. Caller advances the
// flow; popup auto-clears on the next op that physically moves the
// cursor (move_to / click / type_into / drag_select / outro).
setPopup({ text });
},
showMultiChoice(question, options) {
return new Promise<string>((resolve) => {
setMultiChoice({
question,
options,
resolve: (id) => {
setMultiChoice(null);
resolve(id);
},
});
});
},
hidePopup() {
setPopup(null);
if (multiChoice) {
// Defensive — multi_choice is supposed to resolve via user pick,
// but if the runtime aborts mid-question we don't want a dangling
// promise. Resolve with '' so callers can detect dismissal.
multiChoice.resolve('');
setMultiChoice(null);
}
},
getPosition() {
return { ...posRef.current };
},
}));
if (typeof document === 'undefined') return null;
return createPortal(
<>
{/* Cursor body animated by Framer Motion. pointer-events:none so it
never blocks user interaction with the underlying app. */}
<motion.div
animate={controls}
onUpdate={(latest) => {
const x = typeof latest.x === 'number' ? latest.x : posRef.current.x;
const y = typeof latest.y === 'number' ? latest.y : posRef.current.y;
// Avoid React re-renders on every frame; just push to the external
// store so popups (which subscribe via useSyncExternalStore) follow.
if (visible) cursorStore.set({ x, y });
}}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 10500,
pointerEvents: 'none',
// Translate origin: top-left of viewport. The animated x/y is the
// cursor tip's logical position.
transformOrigin: 'top left',
// Visual offset so the arrow's "tip" sits at (x,y) — the SVG below
// is drawn from its top-left, so shift it slightly up-and-left to
// align the pointer.
}}
>
{visible && (
<motion.div
animate={{
// Subtle idle pulse — closer to a soft heartbeat than a
// bouncing scale. Stays out of the way visually.
scale: [1, 1.04, 1],
}}
transition={{
duration: 1.8,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
transform: 'translate(-2px, -2px)',
// Two-layer glow: tight inner ring + softer outer halo.
// Tuned so the cursor reads clearly against light AND dark
// canvases without being distracting.
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
<CursorArrow color={c.accent.primary} />
</motion.div>
)}
</motion.div>
{/* Popups portaled separately so their pointer-events:auto isn't
inherited from the cursor wrapper's pointer-events:none. They
subscribe to cursorStore to track the live position. */}
<AnimatePresence>
{popup && <ACPopup key="popup" text={popup.text} />}
{multiChoice && (
<ACMultiChoice
key="multi-choice"
question={multiChoice.question}
options={multiChoice.options}
onAnswer={multiChoice.resolve}
/>
)}
</AnimatePresence>
</>,
document.body,
);
});
AgenticCursor.displayName = 'AgenticCursor';
export default AgenticCursor;
// Standard arrow cursor shape — 22x22, drawn pointing down-right.
const CursorArrow: React.FC<{ color: string }> = ({ color }) => (
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M3 2 L3 18 L7.5 14 L10 19.5 L13 18 L10.5 12.5 L17 12 Z"
fill={color}
stroke="white"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
);
@@ -0,0 +1,625 @@
// AC runtime — executes a step's ACOp[] sequence by calling into the
// AgenticCursor handle and the gesture/typing helpers. Runs ops sequentially
// with `await`; aborts cleanly when the AbortSignal fires (user dismisses
// panel mid-step, opens a different step, etc).
//
// Pure async. Not a class. Director (in OnboardingDirector.ts) is the
// caller — it owns the lifecycle (AbortController, AC ref, accent color
// resolution from the theme).
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
import {
recordMultiChoice,
markStepCompleted,
clearJustCompleted,
setRunning,
setCurrentStep,
} from '../OnboardingProgressSlice';
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 {
spawnGlowRect,
spawnLiveTargetGlow,
clickRipple,
animateDragSelect,
sleep,
} from './ACGestures';
import { typeInto } from './ACTypewriter';
import type {
ACOp,
AdvanceCondition,
OnboardingStep,
} from '../steps/types';
import type { AgenticCursorHandle } from './AgenticCursor';
interface RunContext {
ac: AgenticCursorHandle;
store: Store<RootState>;
spawnPoint: { x: number; y: number };
accentColor: string;
signal: AbortSignal;
silent: boolean; // suppress popups during dependency re-walks
stepId: string;
// Resolver function for finding a step by id (avoids circular import).
findStep: (id: string) => OnboardingStep | undefined;
// Cleanup for the highlight_section big glow.
highlightCleanup: { current: (() => void) | null };
// Cleanup for the live "AC is pointing here" target ring. Spawned
// alongside startTracking and disposed alongside stopTracking — gives
// the user a clear visual cue of what AC is gesturing toward.
targetGlowCleanup: { current: (() => void) | null };
}
export interface RunStepArgs {
step: OnboardingStep;
spawnPoint: { x: number; y: number };
ac: AgenticCursorHandle;
store: Store<RootState>;
accentColor: string;
signal: AbortSignal;
findStep: (id: string) => OnboardingStep | undefined;
// Optional gate — if step.dependsOn[i] doesn't need re-walking (the
// dependency's outcome is still satisfied), the caller passes a function
// that returns true to skip it.
isDependencySatisfied?: (depId: string) => boolean;
}
export async function runStep(args: RunStepArgs): Promise<void> {
const { step, spawnPoint, ac, store, accentColor, signal, findStep } = args;
store.dispatch(setRunning(true));
store.dispatch(setCurrentStep(step.id));
markStepStarted();
// Bump the bus replay gate so any cached emits from prior steps (or
// the user's exploration in between) can't accidentally satisfy this
// step's wait_user gates. Subsequent once() subscriptions will only
// match emits that happen AFTER this bump.
onboardingBus.resetReplayGate();
report('step_started', { step_id: step.id, stage: step.stage });
const highlightCleanup: { current: (() => void) | null } = { current: null };
const targetGlowCleanup: { current: (() => void) | null } = { current: null };
const ctx: RunContext = {
ac,
store,
spawnPoint,
accentColor,
signal,
silent: false,
stepId: step.id,
findStep,
highlightCleanup,
targetGlowCleanup,
};
try {
await ac.fadeIn(spawnPoint);
// Pre-flight: if the step needs a dashboard route and the user is on
// a different page (Settings closed but they're on /actions, /skills,
// etc), walk them into a dashboard first. Without this, the very
// first move_to of step 3/4/5/6/8 hits a missing target and the
// cursor stalls or strands itself over unrelated UI.
if (step.requiresDashboard && !isInDashboardRoute()) {
await runOps(buildOpenDashboardOps(), ctx);
}
if (step.dependsOn?.length) {
for (const dep of step.dependsOn) {
if (args.isDependencySatisfied?.(dep.stepId)) continue;
const depStep = findStep(dep.stepId);
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
await runOps(depStep.ops, { ...ctx, silent: true, stepId: depStep.id });
}
}
}
await runOps(step.ops, ctx);
report('step_completed', { step_id: step.id });
store.dispatch(markStepCompleted(step.id));
// Belt-and-suspenders: dispatch clearJustCompleted from the runtime
// 950ms after the celebration starts. The OnboardingPanel ALSO has
// its own useEffect timer for this, but the runtime-side timer
// guarantees the celebration unsticks even if the panel's effect
// gets cancelled by a re-render race or AnimatePresence interaction
// — both dispatches go through the same idempotent reducer, so
// double-firing is harmless.
window.setTimeout(() => {
const cur = store.getState().onboardingProgress;
if (cur?.justCompletedStepId === step.id) {
store.dispatch(clearJustCompleted());
}
}, 950);
} catch (err) {
const isAbort =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const msg = (err as Error)?.message ?? String(err);
const isSelectorTimeout = /^waitForSelector:/.test(msg);
if (isAbort) {
report('step_aborted', { step_id: step.id });
} else if (isSelectorTimeout) {
report('step_selector_timeout', { step_id: step.id, error: msg });
} else {
console.error('[onboarding] step failed', step.id, err);
report('step_error', { step_id: step.id, error: msg });
}
// Re-show the panel IMMEDIATELY so the user sees it slide back in
// alongside the cursor's friendly retreat. Otherwise the panel
// stays hidden through the 1.8s recovery popup + fadeOut, which
// looks like the onboarding has crashed.
store.dispatch(setRunning(false));
try {
ac.hidePopup();
ac.stopTracking();
if (highlightCleanup.current) {
highlightCleanup.current();
highlightCleanup.current = null;
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
ac.showPopup(
"No worries — feel free to explore. Tap Show me whenever you're ready.",
);
// 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));
}
} catch {
/* defensive — never let cleanup throw */
}
// Retreat to the original spawnPoint — that's the icon's home
// position from before the panel hid itself, and after the
// setRunning(false) above the panel slides back to that exact spot.
// We previously re-read the live icon rect here, but that fires
// mid-slide-animation and yields transient coordinates (sometimes
// (0,0) if Framer hasn't applied the transform yet) — which is
// why the cursor was landing in the title-bar / kill-button area.
try {
await ac.fadeOut(spawnPoint);
} catch {
/* swallow */
}
} finally {
if (highlightCleanup.current) {
highlightCleanup.current();
highlightCleanup.current = null;
}
if (targetGlowCleanup.current) {
targetGlowCleanup.current();
targetGlowCleanup.current = null;
}
store.dispatch(setRunning(false));
clearStepTiming();
}
}
async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
if (ctx.signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
// Op-level telemetry — gives drop-off granularity beyond
// step_started / step_completed. Skipped during silent dependency
// re-walks to avoid double-reporting.
if (!ctx.silent) {
report('op_started', {
step_id: ctx.stepId,
op_index: i,
op_kind: op.kind,
});
}
const opStart = Date.now();
try {
await runOp(op, ctx);
if (!ctx.silent) {
report('op_completed', {
step_id: ctx.stepId,
op_index: i,
op_kind: op.kind,
duration_ms: Date.now() - opStart,
});
}
} catch (err) {
if (!ctx.silent && (err as DOMException)?.name !== 'AbortError') {
report('op_failed', {
step_id: ctx.stepId,
op_index: i,
op_kind: op.kind,
duration_ms: Date.now() - opStart,
error: String(err),
});
}
throw err;
}
}
}
async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const { ac, store, signal, accentColor } = ctx;
// Ops that physically move the cursor or change context implicitly
// clear any active popup, sticky tracker, AND active highlight glow —
// the previous instruction / pin / glow no longer applies once the
// cursor is heading somewhere new. wait_user / delay / popup /
// highlight_section / multi_choice keep all three visible (in
// particular, wait_user keeps tracking so the cursor stays glued to
// its target while we wait for the user's click).
const clearsTransients =
op.kind === 'move_to' ||
op.kind === 'click' ||
op.kind === 'type_into' ||
op.kind === 'drag_select' ||
op.kind === 'outro';
if (clearsTransients) {
ac.hidePopup();
ac.stopTracking();
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
if (ctx.targetGlowCleanup.current) {
ctx.targetGlowCleanup.current();
ctx.targetGlowCleanup.current = null;
}
}
switch (op.kind) {
case 'move_to': {
const el = await waitForSelector(op.target);
const scrolled = scrollIntoViewIfNeeded(el);
// Cheaper rect-settle: instead of unconditionally sleeping 180ms
// after every scroll AND a possible 200ms retry, read the rect
// immediately and only wait if it actually looks bad. In the
// happy path (target already in view, layout stable), this skips
// both sleeps entirely.
const offX = op.offset?.x ?? 0;
const offY = op.offset?.y ?? 0;
const TITLE_BAR_BOTTOM = 38;
const looksDegenerate = (rr: DOMRect, y: number): boolean =>
y < TITLE_BAR_BOTTOM ||
y > window.innerHeight ||
rr.width === 0 ||
rr.height === 0;
let r = el.getBoundingClientRect();
let cx = r.left + r.width / 2 + offX;
let cy = r.top + r.height / 2 + offY;
if (scrolled || looksDegenerate(r, cy)) {
// Either we just kicked off a smooth scroll, or the rect looks
// mid-commit. Wait one frame's worth (16ms) and re-read; only
// fall back to the longer wait if it's still bad.
await sleep(scrolled ? 180 : 16);
r = el.getBoundingClientRect();
cx = r.left + r.width / 2 + offX;
cy = r.top + r.height / 2 + offY;
if (looksDegenerate(r, cy)) {
await sleep(160);
r = el.getBoundingClientRect();
cx = r.left + r.width / 2 + offX;
cy = r.top + r.height / 2 + offY;
}
}
if (looksDegenerate(r, cy)) {
throw new Error(`waitForSelector: "${op.target}" rect did not settle`);
}
await ac.moveTo(cx, cy);
ac.startTracking(op.target, op.offset);
// Live target glow — gives the user a clear "this is what AC
// is pointing at" visual. Replaces any previous target glow.
if (ctx.targetGlowCleanup.current) {
ctx.targetGlowCleanup.current();
}
ctx.targetGlowCleanup.current = spawnLiveTargetGlow(el, accentColor);
return;
}
case 'popup': {
if (ctx.silent) return;
ac.showPopup(op.text);
// Hold the popup long enough to actually read it before the next
// op runs (which usually clears transients). Heuristic: typing
// takes ~32ms/char; reading at average human speed (~250 wpm,
// ~5 chars/word) is ~48ms/char. Total budget = stream time +
// read time, capped 1500..6000ms so single-word popups still
// breathe and long ones don't bore the user.
const STREAM = 32;
const READ = 55;
const total = op.text.length * (STREAM + READ);
const hold = Math.max(1500, Math.min(6000, total));
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, hold);
const onAbort = () => {
window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort);
});
return;
}
case 'multi_choice': {
if (ctx.silent) return;
const id = await ac.showMultiChoice(op.question, op.options);
if (id) {
store.dispatch(
recordMultiChoice({ stepId: ctx.stepId, opId: op.opId, answerId: id }),
);
report('multi_choice_answered', {
step_id: ctx.stepId,
op_id: op.opId,
answer_id: id,
});
}
const choice = op.options.find((o) => o.id === id);
if (choice?.thenOps?.length) {
await runOps(choice.thenOps, ctx);
}
return;
}
case 'highlight_section': {
const el = await waitForSelector(op.target);
// Replace any previous highlight first so we don't stack glows.
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
const cleanup = spawnGlowRect(el, accentColor);
ctx.highlightCleanup.current = cleanup;
// Only show the popup if one was supplied — the runtime relies on
// the next op (typically wait_user) to keep the glow visible while
// the user reads. The glow is cleared by the next clearsTransients
// op (move_to / click / type_into / drag_select / outro) or at
// step-end in the runStep finally block.
if (op.popup && !ctx.silent) {
ac.showPopup(op.popup);
}
// Optional minimum dwell so very-fast paths still register the
// glow visually. Defaults to a short beat; explicit durationMs
// overrides.
await sleep(op.durationMs ?? 600);
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 });
if (ctx.targetGlowCleanup.current) ctx.targetGlowCleanup.current();
ctx.targetGlowCleanup.current = spawnLiveTargetGlow(el, accentColor);
await typeInto(el, op.text, { speedMs: op.speedMs });
return;
}
case 'click': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
await ac.moveTo(x, y);
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
try {
el.click();
} catch {
/* swallow — degrade to visual-only */
}
}
// Do NOT start tracking after a click. Many click targets are
// ephemeral — chat send buttons morph into stop buttons after
// submit, modal triggers unmount when the modal opens, etc.
// Tracking a disappearing element triggers lost-target → step
// abort, which kills the step before outro runs and prevents
// markStepCompleted from firing (the user is stuck on the same
// step forever). The cursor's last-set position from moveTo holds
// steady until the next op explicitly moves it.
return;
}
case 'drag_select': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
const fromX = r.left - 12;
const fromY = r.top - 12;
const toX = r.right + 12;
const toY = r.bottom + 12;
await ac.moveTo(fromX, fromY);
await animateDragSelect({ fromX, fromY, toX, toY }, accentColor);
await ac.moveTo(toX, toY);
// No tracking after drag_select — the visual ends at a calculated
// bottom-right corner, not the center of any element. Next op
// (typically wait_user or move_to) takes over positioning.
return;
}
case 'wait_user': {
await waitForCondition(op.condition, signal, store, op.timeoutMs);
ac.hidePopup();
// Quick layout-settle — one frame is enough in 95% of cases
// (React commits on the next animation frame). The move_to
// op also has its own settle if the rect comes out degenerate,
// so this is just a cheap "let the click handler run" beat.
await sleep(16);
return;
}
case 'delay': {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, op.ms);
const onAbort = () => {
window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort);
});
return;
}
case 'outro': {
await ac.fadeOut(ctx.spawnPoint);
return;
}
}
}
// Bring the target into view if any part of it is outside the viewport.
// Returns true if a scroll was actually triggered, false otherwise — the
// runtime uses this to decide whether to wait the smooth-scroll-settle
// beat. Scrolling-already-visible-element + 180ms wait would be pure
// added latency on every cursor move (~10s across the whole tour).
function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const vw = window.innerWidth;
const PAD = 24;
const offTop = r.top < PAD;
const offBottom = r.bottom > vh - PAD;
const offLeft = r.left < PAD;
const offRight = r.right > vw - PAD;
if (!offTop && !offBottom && !offLeft && !offRight) return false;
try {
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
} catch {
// Older webview / jsdom — fall back to instant scroll.
try {
el.scrollIntoView();
} catch {
/* nothing to do — tracker will still try to pin once visible */
}
}
return true;
}
// True when the current URL is `#/dashboard/<id>` (a specific dashboard,
// where the toolbar with + / browser / etc. mounts). False on `#/`
// (dashboard list), `#/skills`, etc. HashRouter only — production app
// uses HashRouter so window.location.hash is the source of truth.
//
// Note: path is singular `/dashboard/`, not `/dashboards/` — that mismatch
// previously had the runtime thinking the user was always in a dashboard
// (since neither shape ever matched), which is why "Show me" from the
// Actions/Skills pages would barrel into a missing-+ button.
function isInDashboardRoute(): boolean {
const h = window.location.hash || '';
return /^#\/dashboard\/[^/?#]+/.test(h);
}
// Ops the runtime prepends when a step requires being inside a dashboard
// but the user isn't. First click expands the Dashboards section in the
// sidebar (so the rows render), second click selects the first row to
// navigate into that dashboard. Both clicks are user-driven — we don't
// teleport — so the user understands where they ended up.
function buildOpenDashboardOps(): ACOp[] {
return [
{ kind: 'move_to', target: 'sidebar-dashboards' },
{ kind: 'popup', text: 'Open the Dashboards list.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'sidebar-dashboards' },
timeoutMs: 60000,
},
{ kind: 'move_to', target: 'dashboard-row-first' },
{ kind: 'popup', text: 'Click into a dashboard to continue.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'dashboard-row-first' },
timeoutMs: 60000,
},
];
}
function waitForCondition(
cond: AdvanceCondition,
signal: AbortSignal,
store: Store<RootState>,
timeoutMs?: number,
): Promise<void> {
if (signal.aborted) {
return Promise.reject(new DOMException('aborted', 'AbortError'));
}
return new Promise((resolve, reject) => {
let cleanup: () => void = () => {};
let timer: number | null = null;
const finish = () => {
cleanup();
if (timer !== null) window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
resolve();
};
const onAbort = () => {
cleanup();
if (timer !== null) window.clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort);
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
cleanup();
signal.removeEventListener('abort', onAbort);
// Treat timeout as soft-success — the user may have done the thing
// without our condition firing (e.g. they opened the page some other
// way). Better than freezing the panel.
resolve();
}, timeoutMs);
}
switch (cond.kind) {
case 'click_target': {
const handler = (e: Event) => {
const el = e.target as HTMLElement | null;
if (
el?.closest(
`[data-onboarding="${cond.target}"], [data-select-type="${cond.target}"]`,
)
) {
finish();
}
};
document.addEventListener('click', handler, true);
cleanup = () => document.removeEventListener('click', handler, true);
return;
}
case 'redux_predicate': {
const check = () => {
const value = cond.selector(store.getState());
const ok =
cond.equals !== undefined
? value === cond.equals
: cond.truthy
? Boolean(value)
: Boolean(value);
if (ok) finish();
};
check();
const unsub = store.subscribe(check);
cleanup = unsub;
return;
}
case 'event_bus': {
const off = onboardingBus.once(cond.event as OnboardingEvent, () =>
finish(),
);
cleanup = off;
return;
}
}
});
}
@@ -0,0 +1,87 @@
// Module-level signal for the cursor's logical position. Both the
// AgenticCursor component (which renders the arrow) and ACPopup /
// ACMultiChoice (which need to render relative to it) read from here.
//
// Performance contract: the cursor itself is driven by Framer Motion's
// imperative `controls.set`, which doesn't trigger React renders. This
// store exists ONLY so popups can follow during animation. Subscribers
// re-render on every notification, so naive frame-rate notifications
// would re-render the popup 60 times/sec — wasteful since popup
// position barely changes between sub-pixel cursor frames.
//
// We coalesce position writes to ~30fps via rAF and only notify when
// the cursor has moved more than COALESCE_PX. Visibility flips are
// flushed immediately (rare event, user-visible).
import { useSyncExternalStore } from 'react';
interface CursorPos {
x: number;
y: number;
visible: boolean;
}
let state: CursorPos = { x: 0, y: 0, visible: false };
let pendingState: CursorPos | null = null;
const listeners = new Set<() => void>();
// Sub-pixel cursor moves don't change popup position visibly, but they
// still trigger React renders. 1.5px is enough to feel smooth without
// re-rendering on every frame.
const COALESCE_PX = 1.5;
let rafScheduled = false;
function flush() {
rafScheduled = false;
if (!pendingState) return;
state = pendingState;
pendingState = null;
listeners.forEach((l) => l());
}
export const cursorStore = {
get: () => state,
set(next: Partial<CursorPos>) {
const merged = { ...(pendingState ?? state), ...next };
// Visibility transitions bypass coalescing — these are user-visible
// mounts/unmounts of popups, must flush immediately.
const visibilityChanged = merged.visible !== state.visible;
const dx = Math.abs(merged.x - state.x);
const dy = Math.abs(merged.y - state.y);
const significantMove = dx >= COALESCE_PX || dy >= COALESCE_PX;
if (visibilityChanged) {
state = merged;
pendingState = null;
rafScheduled = false;
listeners.forEach((l) => l());
return;
}
if (!significantMove) {
// Below threshold: update pending state silently. The next
// significant move will pick up the latest pending values.
pendingState = merged;
return;
}
pendingState = merged;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(flush);
}
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
export function useCursorPosition(): CursorPos {
return useSyncExternalStore(
cursorStore.subscribe,
cursorStore.get,
cursorStore.get,
);
}
@@ -0,0 +1,123 @@
// Tiny mitt-style event bus for onboarding-v2 advance conditions that
// don't have a natural Redux signal. Each emit site is a one-liner at the
// success path of a feature (browser:spawned at the end of spawnBrowser,
// settings:closed when the modal closes, etc).
//
// Why not Redux for everything: some events (browser navigated, app
// generation milestones) involve backend round-trips and the Redux state
// lags by a tick. Explicit emit at the success callsite is more
// deterministic than observing state.
export type OnboardingEvent =
| 'browser:spawned'
| 'browser:navigated'
| 'settings:closed'
| 'chat:message_sent'
| 'app:generation_started'
| 'app:generation_done'
| 'skill:installed'
| 'action:toggled'
| 'mode:created'
| 'note:created'
| 'element_selection:toggled'
| 'agent:spawned'
| 'agent:completed'
| 'agent:attached_to_browser';
type Handler = (...args: unknown[]) => void;
// Replay window — see explanation on once() below. Tight on purpose so
// previous steps' emits can't accidentally satisfy current-step waits;
// the gating below is a stronger guarantee than the time window alone.
const REPLAY_WINDOW_MS = 500;
class OnboardingBus {
private handlers = new Map<OnboardingEvent, Set<Handler>>();
// recentEmits stores the timestamp of the most recent emit per event.
// Used by once() to satisfy a subscription that races a synchronous
// emit (e.g. AC.click() → handleSend → emit happens BEFORE the next
// op's wait_user gets to register). Without this, the wait sits idle
// for its full timeout.
private recentEmits = new Map<OnboardingEvent, number>();
// Monotonic gate id. Director bumps this whenever a new step starts;
// any once() subscriber that registers will only consider replays
// emitted after that bump. Solves the cross-step contamination case
// where step 6 emitted chat:message_sent ages ago and step 8's
// identical wait satisfies on the stale cached timestamp.
private gateId = 0;
private gateTs = 0;
/**
* Bump the gate. Director calls this at the start of every new step
* (and at runStep cleanup). All recentEmits become invisible to
* subsequent once() subscribers they only match emits that happen
* AFTER the bump. Also clears the recentEmits map outright as
* defense-in-depth the gate alone would suffice but keeping a
* stale map around for hours is wasteful.
*/
resetReplayGate(): void {
this.gateId += 1;
this.gateTs = Date.now();
this.recentEmits.clear();
}
on(event: OnboardingEvent, handler: Handler): () => void {
let set = this.handlers.get(event);
if (!set) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
return () => set!.delete(handler);
}
emit(event: OnboardingEvent, ...args: unknown[]): void {
this.recentEmits.set(event, Date.now());
const set = this.handlers.get(event);
if (!set) return;
// Snapshot to avoid mutation during iteration.
[...set].forEach((h) => {
try {
h(...args);
} catch (err) {
console.warn('[onboarding] bus handler threw', event, err);
}
});
}
once(event: OnboardingEvent, handler: Handler): () => void {
// Replay path: if this exact event was emitted within the last
// REPLAY_WINDOW_MS *AND* after the most recent gate bump, fire
// the handler now and don't register at all. The gate check is
// what prevents stale step-6 emits from satisfying step-8 waits.
const last = this.recentEmits.get(event);
if (
last !== undefined &&
last > this.gateTs &&
Date.now() - last <= REPLAY_WINDOW_MS
) {
queueMicrotask(() => {
try {
handler();
} catch (err) {
console.warn('[onboarding] bus replay handler threw', event, err);
}
});
return () => {};
}
const off = this.on(event, (...args) => {
off();
handler(...args);
});
return off;
}
}
export const onboardingBus = new OnboardingBus();
// Expose on window in dev for debugging — tests and the browser console
// can poke `window.__OPENSWARM_ONBOARDING_BUS__.emit('browser:spawned')`
// to advance steps without going through real product UI.
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_ONBOARDING_BUS__ = onboardingBus;
}
@@ -0,0 +1,28 @@
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
setPanelMode,
setCurrentStep,
markStepCompleted,
clearJustCompleted,
setRunning,
recordMultiChoice,
resetTour,
type PanelMode,
} from '../OnboardingProgressSlice';
export function useOnboardingProgress() {
const state = useAppSelector((s) => s.onboardingProgress);
const dispatch = useAppDispatch();
return {
...state,
setPanelMode: (m: PanelMode) => dispatch(setPanelMode(m)),
setCurrentStep: (id: string | null) => dispatch(setCurrentStep(id)),
markCompleted: (id: string) => dispatch(markStepCompleted(id)),
clearJustCompleted: () => dispatch(clearJustCompleted()),
setRunning: (running: boolean) => dispatch(setRunning(running)),
recordMultiChoice: (stepId: string, opId: string, answerId: string) =>
dispatch(recordMultiChoice({ stepId, opId, answerId })),
resetTour: () => dispatch(resetTour()),
};
}
@@ -0,0 +1,9 @@
// Public surface for the Onboarding v2 system. AppShell mounts
// <OnboardingRoot/> once; everything else is internal.
export { default as OnboardingRoot } from './OnboardingRoot';
export { onboardingDirector } from './OnboardingDirector';
export { onboardingBus } from './eventBus';
export type { OnboardingEvent } from './eventBus';
export { S as OnboardingSelectors } from './selectors';
export { useOnboardingProgress } from './hooks/useOnboardingProgress';
@@ -0,0 +1,199 @@
// Central registry of every data-onboarding (or data-select-type) string the
// onboarding v2 system targets. Step files import S.* — never inline literals
// — so a refactor that renames a selector breaks at type-check time and we
// can grep for usages.
//
// New keys added by v2 are commented; pre-existing keys (already wired in
// product code before v2) are noted with [existing].
export const S = {
// [existing] sidebar / nav
sidebarSkills: 'sidebar-skills',
sidebarActions: 'sidebar-actions',
sidebarModes: 'sidebar-modes',
sidebarApps: 'sidebar-apps',
// new — sidebar
sidebarSettingsButton: 'sidebar-settings-button',
sidebarDashboards: 'sidebar-dashboards',
// First row inside the expanded Dashboards section. The "click into a
// dashboard" hop targets this so the user lands inside a dashboard
// route (where the toolbar + and browser button actually exist).
dashboardRowFirst: 'dashboard-row-first',
// [existing] dashboard toolbar
newAgentButton: 'new-agent-button',
browserButton: 'browser-button',
canvasControls: 'canvas-controls',
// new — dashboard toolbar
dashboardToolbarApps: 'dashboard-toolbar-apps',
// [existing] agent card
agentCard: 'agent-card', // matched via data-select-type as fallback
// new — settings modal
settingsModelsTab: 'settings-models-tab',
settingsCloseButton: 'settings-close-button',
settingsProSection: 'settings-pro-section',
settingsExternalSubs: 'settings-external-subs',
settingsApiKeys: 'settings-api-keys',
settingsRestartTour: 'settings-restart-tour',
// new — agent chat input
chatInput: 'chat-input',
chatSendButton: 'chat-send-button',
elementSelectionToggle: 'element-selection-toggle',
// new — actions / tools page
actionsRedditToggle: 'actions-reddit-toggle',
actionsRedditChevron: 'actions-reddit-chevron',
actionsSubredditsChevron: 'actions-subreddits-chevron',
actionsPermissionToggle: 'actions-permission-toggle',
// new — skills page
skillItemPdf: 'skill-item-pdf',
skillInstallButton: 'skill-install-button',
skillBuilderFab: 'skill-builder-fab',
// new — apps / views page
appsNewButton: 'apps-new-button',
appBuilderInput: 'app-builder-input',
appBuilderSubmit: 'app-builder-submit',
appCardLatest: 'app-card-latest',
// new — browser card
browserUrlBar: 'browser-url-bar',
} as const;
export type SelectorKey = (typeof S)[keyof typeof S];
// Selectors that may legitimately match multiple elements (one per agent
// card). For these we want the *newest* card — the one the user just
// spawned via the + button — not whichever agent happens to be earliest
// in DOM order. Without this scoping, step 6's "type into chat input"
// would hijack the existing "Open Swarm documentation" agent from step 5
// instead of the new orchestrator.
const PER_AGENT_SELECTORS = new Set([
'chat-input',
'chat-send-button',
'element-selection-toggle',
]);
// Resolve a selector string to a live DOM node, falling back to data-select-type
// if data-onboarding doesn't match. Returns null if not found.
//
// Per-agent selectors get special treatment: querySelectorAll all matches
// and pick the one inside the LAST agent-card in DOM order (cards mount
// at the end as they're created, so the last is the newest). Single-match
// selectors are unchanged.
export function resolveSelector(target: string): HTMLElement | null {
const escaped = (window as any).CSS?.escape?.(target) ?? target;
if (PER_AGENT_SELECTORS.has(target)) {
const all = document.querySelectorAll<HTMLElement>(
`[data-onboarding="${escaped}"]`,
);
if (all.length === 0) return null;
if (all.length === 1) return all[0];
// Priority 1: the App Builder's AgentChat scope on /apps/. The
// App Builder mounts a regular AgentChat in the left pane —
// not wrapped in [data-select-type="agent-card"] — so without
// this explicit scope, step 8's chat-input would fall through
// to "last DOM match" and AC would type into nothing visible.
const appBuilderScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="app-builder"]',
);
if (appBuilderScope) {
const scoped = appBuilderScope.querySelector<HTMLElement>(
`[data-onboarding="${escaped}"]`,
);
if (scoped) return scoped;
}
// Priority 2: the dock toolbar's ChatInput, when open. This is the
// "draft agent" the user just opened by clicking + — higher
// priority than any existing agent-card so step 5/6's chat-input /
// send-button / element-selection-toggle ops route to the dock,
// not whichever agent-card is freshest in the DOM.
const dockScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="dock"]',
);
if (dockScope) {
const scoped = dockScope.querySelector<HTMLElement>(
`[data-onboarding="${escaped}"]`,
);
if (scoped) return scoped;
}
// Priority 2: the agent-card with the newest data-onboarding-spawn-ms
// (set from session.created_at). Used during/after the dock has been
// collapsed and a real agent card exists.
const cards = document.querySelectorAll<HTMLElement>(
'[data-select-type="agent-card"]',
);
let newestCard: HTMLElement | null = null;
let newestSpawnMs = -Infinity;
cards.forEach((card) => {
const raw = card.getAttribute('data-onboarding-spawn-ms');
const n = raw ? Number(raw) : NaN;
if (Number.isFinite(n) && n > newestSpawnMs) {
newestSpawnMs = n;
newestCard = card;
}
});
if (!newestCard && cards.length > 0) {
newestCard = cards[cards.length - 1];
}
if (newestCard) {
const scoped = (newestCard as HTMLElement).querySelector<HTMLElement>(
`[data-onboarding="${escaped}"]`,
);
if (scoped) return scoped;
}
return all[all.length - 1];
}
const el =
(document.querySelector(`[data-onboarding="${escaped}"]`) as HTMLElement | null) ??
(document.querySelector(`[data-select-type="${escaped}"]`) as HTMLElement | null);
return el;
}
// 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).
export function waitForSelector(
target: string,
timeoutMs = 8000,
): Promise<HTMLElement> {
const existing = resolveSelector(target);
if (existing) return Promise.resolve(existing);
return new Promise((resolve, reject) => {
const start = Date.now();
const obs = new MutationObserver(() => {
const el = resolveSelector(target);
if (el) {
obs.disconnect();
resolve(el);
} else if (Date.now() - start > timeoutMs) {
obs.disconnect();
reject(new Error(`waitForSelector: "${target}" did not appear within ${timeoutMs}ms`));
}
});
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
// Also poll as a safety net — MutationObserver misses nothing in practice
// but the timeout path needs a way to fire even if the DOM is quiet.
setTimeout(() => {
const el = resolveSelector(target);
if (el) {
obs.disconnect();
resolve(el);
} else {
obs.disconnect();
reject(new Error(`waitForSelector: "${target}" did not appear within ${timeoutMs}ms`));
}
}, timeoutMs);
});
}
@@ -0,0 +1,29 @@
import type { OnboardingStep, StepStage } from './types';
import { step01 } from './step01_connectModel';
import { step02 } from './step02_enableActions';
import { step03 } from './step03_launchAgent';
import { step04 } from './step04_useBrowser';
import { step05 } from './step05_agentUseBrowser';
import { step06 } from './step06_agentControlAgents';
import { step07 } from './step07_installSkill';
import { step08 } from './step08_makeApp';
export const STEPS: OnboardingStep[] = [
step01,
step02,
step03,
step04,
step05,
step06,
step07,
step08,
];
export function findStepById(id: string): OnboardingStep | undefined {
return STEPS.find((s) => s.id === id);
}
export const STAGE_GROUPS: { stage: StepStage; steps: OnboardingStep[] }[] = [
{ stage: 'get_started', steps: STEPS.filter((s) => s.stage === 'get_started') },
{ stage: 'learn_features', steps: STEPS.filter((s) => s.stage === 'learn_features') },
];
@@ -0,0 +1,54 @@
// Shared skipIf predicates. Each returns true when the corresponding step
// is already-done in current Redux state — used to pre-mark completed
// milestones for upgrading users and to short-circuit "Show me" if the
// user already did the thing.
import type { RootState } from '@/shared/state/store';
export function hasModelConnected(s: RootState): boolean {
const d = s.settings.data as any;
if (!d) return false;
if (d.connection_mode === 'openswarm-pro' && d.openswarm_bearer_token) return true;
return Boolean(
d.anthropic_api_key ||
d.openai_api_key ||
d.google_api_key ||
d.openrouter_api_key,
);
}
export function hasAnyToolEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
// Match the Switch's read in Tools.tsx: `tool.enabled !== false`. Tools
// installed before the `enabled` field existed have it as undefined,
// which the Switch treats as "on" — so we should too. Otherwise step 2
// never auto-skips for users who already have integrations installed.
return Object.values(items).some((t: any) => t?.enabled !== false);
}
// True when a Reddit-shaped tool is currently enabled. Used by step 2's
// wait-for-toggle so the wait only resolves when Reddit is actually ON,
// regardless of how many times the user toggles. Catches the case where
// the user's first click turns OFF an already-enabled Reddit, then
// toggles back on — naive click_target waits would advance on the
// off-click and leave AC out of sync.
export function isRedditEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
return Object.values(items).some((t: any) => {
const name = (t?.name ?? '').toLowerCase();
const command = (t?.command ?? '').toLowerCase();
const isReddit = name === 'reddit' || command.includes('reddit');
return isReddit && t?.enabled !== false;
});
}
export function hasAnyAgentLaunched(s: RootState): boolean {
const sessions = s.agents?.sessions ?? {};
return Object.keys(sessions).length > 0;
}
export function hasAnySkillInstalled(s: RootState): boolean {
const items = s.skills?.items ?? [];
if (Array.isArray(items)) return items.length > 0;
return Object.keys(items).length > 0;
}
@@ -0,0 +1,84 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasModelConnected } from './skipPredicates';
export const step01: OnboardingStep = {
id: 'connect_model',
stage: 'get_started',
index: 1,
title: 'Connect an AI model',
description: 'This is the brain behind your agents.',
videoSrc: '/onboarding-videos/v2/01.mp4',
videoDurationLabel: '0:24',
skipIf: hasModelConnected,
ops: [
{ kind: 'move_to', target: S.sidebarSettingsButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarSettingsButton },
},
{ kind: 'move_to', target: S.settingsModelsTab },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.settingsModelsTab },
},
{
kind: 'multi_choice',
opId: 'connect_method',
question: 'How would you like to connect an AI model?',
options: [
{
id: 'pro',
label: 'Open Swarm Pro subscription',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsProSection,
popup: 'Choose a tier',
},
],
},
{
id: 'subscription',
label: 'I already have an AI subscription',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsExternalSubs,
popup: 'Connect a subscription',
},
],
},
{
id: 'api_key',
label: 'I have an API key',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsApiKeys,
popup: 'Add an API key',
},
],
},
],
},
{
kind: 'wait_user',
condition: {
kind: 'redux_predicate',
selector: hasModelConnected,
truthy: true,
},
hint: 'Finish connecting your model.',
},
{ kind: 'move_to', target: S.settingsCloseButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'settings:closed' },
},
{ kind: 'outro' },
],
};
@@ -0,0 +1,62 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyToolEnabled, isRedditEnabled } from './skipPredicates';
export const step02: OnboardingStep = {
id: 'enable_actions',
stage: 'get_started',
index: 2,
title: 'Enable agentic actions',
description: 'Allow agents to work across your apps.',
videoSrc: '/onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
skipIf: hasAnyToolEnabled,
ops: [
{ kind: 'move_to', target: S.sidebarActions },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarActions },
},
// Reddit toggle. Wait on REDUX STATE (Reddit enabled), not a single
// click. If the user's Reddit was already on and they accidentally
// toggle it off, then back on, we still advance correctly when it
// ends up enabled — instead of the wait resolving on the first
// click (toggle-off) and AC drifting out of sync.
{ kind: 'move_to', target: S.actionsRedditToggle },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: {
kind: 'redux_predicate',
selector: isRedditEnabled,
truthy: true,
},
timeoutMs: 90000,
},
// After Reddit is enabled, expand its action group via the chevron.
{ kind: 'move_to', target: S.actionsRedditChevron },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.actionsRedditChevron },
},
// Now drill into the Subreddits sub-group.
{ kind: 'move_to', target: S.actionsSubredditsChevron },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.actionsSubredditsChevron },
},
// Hover (no click) over the permission toggle to draw attention,
// popup explaining what it is, then just wait a beat — spec says
// no user input needed past this point.
{ kind: 'move_to', target: S.actionsPermissionToggle },
{
kind: 'popup',
text: 'You can set permissions for individual actions here.',
},
{ kind: 'delay', ms: 3500 },
{ kind: 'outro' },
],
};
@@ -0,0 +1,42 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyAgentLaunched } from './skipPredicates';
export const step03: OnboardingStep = {
id: 'launch_agent',
stage: 'get_started',
index: 3,
title: 'Launch your first Agent',
description: 'Click + to fire up a new Agent in a dashboard.',
videoSrc: '/onboarding-videos/v2/03.mp4',
videoDurationLabel: '0:24',
skipIf: hasAnyAgentLaunched,
requiresDashboard: true,
ops: [
{ kind: 'move_to', target: S.newAgentButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Chat input mounts asynchronously after + is clicked. waitForSelector
// inside the runtime handles the small delay before type_into runs.
{
kind: 'type_into',
target: S.chatInput,
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z',
speedMs: 12,
},
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
// the user lands on a typed-but-unsent prompt and has to hit send
// themselves, which is awkward and out-of-line with the other steps.
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
timeoutMs: 30000,
},
{ kind: 'outro' },
],
};
@@ -0,0 +1,29 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
export const step04: OnboardingStep = {
id: 'use_browser',
stage: 'get_started',
index: 4,
title: 'Use the built-in browser',
description:
'No more jumping between apps. You and your agents work in one place.',
videoSrc: '/onboarding-videos/v2/04.mp4',
videoDurationLabel: '0:18',
// Runtime auto-prepends a "click into a dashboard" hop when the user
// isn't already on a #/dashboards/:id route. No need to repeat that in
// ops — the previous version of this step pointed at the section
// header (which only toggles the sidebar list) and never actually
// navigated the user into a dashboard.
requiresDashboard: true,
ops: [
{ kind: 'move_to', target: S.browserButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'browser:spawned' },
timeoutMs: 60000,
},
{ kind: 'outro' },
],
};
@@ -0,0 +1,51 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
export const step05: OnboardingStep = {
id: 'agent_use_browser',
stage: 'learn_features',
index: 5,
title: 'Have an agent use the browser',
description: 'Let an agent take control of your browser.',
videoSrc: '/onboarding-videos/v2/05.mp4',
videoDurationLabel: '0:30',
requiresDashboard: true,
dependsOn: [{ stepId: 'use_browser', reopen: 'walk_again' }],
ops: [
{ kind: 'move_to', target: S.newAgentButton },
{ kind: 'popup', text: 'Spin up a new agent that will use the browser.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
{ kind: 'move_to', target: S.elementSelectionToggle },
{ kind: 'popup', text: 'Click here to attach a browser to this agent.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
// AC demonstrates the drag-select on the browser card, then asks the
// user to do the same gesture for real (the actual product wires up
// the selection during a real mouse drag).
{ kind: 'drag_select', target: 'browser-card' },
{
kind: 'popup',
text: 'Now you try — drag a box around the browser card to attach it.',
},
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'agent:attached_to_browser' },
timeoutMs: 90000,
},
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
target: S.chatInput,
text: 'Pull up the open swarm website (openswarm.com) and find the docs',
speedMs: 12,
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
{ kind: 'outro' },
],
};
@@ -0,0 +1,71 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
export const step06: OnboardingStep = {
id: 'agent_control_agents',
stage: 'learn_features',
index: 6,
title: 'Have an agent control other agents',
description: 'Let an agent orchestrate other agents.',
videoSrc: '/onboarding-videos/v2/06.mp4',
videoDurationLabel: '0:34',
requiresDashboard: true,
ops: [
// The OnboardingRoot pre-runs `seed-orchestration-demo` before a step-6
// start so a stub "research" agent already exists on the canvas. The
// popup below tells the user to imagine they made it themselves.
{
kind: 'popup',
text: "Pretend this agent already did some research for you. We'll have a NEW agent orchestrate it.",
},
{ kind: 'move_to', target: S.newAgentButton },
{ kind: 'popup', text: 'Spin up a new agent — this one will be the orchestrator.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
{ kind: 'move_to', target: S.elementSelectionToggle },
{ kind: 'popup', text: 'Click here to attach the existing agent.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
{ kind: 'drag_select', target: 'agent-card' },
{
kind: 'popup',
text: 'Now you try — drag a box around the agent card to attach it as a sub-agent.',
},
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'agent:attached_to_browser' },
// Reuses the same attached event as step 5 for now — backend emits
// it for any element-selection attachment regardless of element type.
timeoutMs: 90000,
},
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
target: S.chatInput,
text: 'Create a pdf report of the research and save it to my downloads',
speedMs: 12,
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait for the user's message to actually go out — short wait, just
// to confirm the orchestration kicked off. Don't wait for the agent
// to fully finish: orchestrators legitimately run for minutes,
// sub-agents loop while doing real work, and trapping the user
// in step 6 until everything settles is the worst possible UX.
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
timeoutMs: 30000,
},
{
kind: 'popup',
text: 'Your orchestrator is on it. The PDF will land in Downloads when the sub-agents finish — feel free to keep exploring while they work.',
},
{ kind: 'delay', ms: 4000 },
{ kind: 'outro' },
],
};
@@ -0,0 +1,47 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnySkillInstalled } from './skipPredicates';
export const step07: OnboardingStep = {
id: 'install_skill',
stage: 'learn_features',
index: 7,
title: 'Install a skill',
description: 'Teach agents how to handle specific tasks.',
videoSrc: '/onboarding-videos/v2/07.mp4',
videoDurationLabel: '0:24',
skipIf: hasAnySkillInstalled,
ops: [
{ kind: 'move_to', target: S.sidebarSkills },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarSkills },
},
{ kind: 'move_to', target: S.skillItemPdf },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.skillItemPdf },
},
{ kind: 'move_to', target: S.skillInstallButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'skill:installed' },
timeoutMs: 60000,
},
{
kind: 'popup',
text: 'Now any agent will be much better at working with PDFs.',
},
{ kind: 'move_to', target: S.skillBuilderFab },
{ kind: 'click', target: S.skillBuilderFab, simulate: true },
{
kind: 'popup',
text: 'You can also prompt new skills into existence with the skill builder here.',
},
{ kind: 'delay', ms: 3500 },
{ kind: 'outro' },
],
};
@@ -0,0 +1,57 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
export const step08: OnboardingStep = {
id: 'make_app',
stage: 'learn_features',
index: 8,
title: 'Make an App',
description: 'Prompt interactive applications into existence.',
videoSrc: '/onboarding-videos/v2/08.mp4',
videoDurationLabel: '0:42',
ops: [
{ kind: 'move_to', target: S.sidebarApps },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarApps },
},
{ kind: 'move_to', target: S.appsNewButton },
{ kind: 'popup', text: 'Click here!' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.appsNewButton },
},
// The App Builder chat lives in the left pane on /apps/new — a
// regular ChatInput instance, so data-onboarding="chat-input"
// resolves to it.
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
target: S.chatInput,
text: 'Make me a pdf previewer app',
speedMs: 12,
},
// AC auto-clicks send per spec ("the AC should auto send this").
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait only for chat:message_sent (the prompt actually going out).
// Don't wait for app:generation_done — the App Builder agent can
// take any of several legitimate paths: save as a standalone HTML
// to ~/Downloads and open in the system browser, save as an
// OpenSwarm Output, or skip saving entirely. We can't reliably
// detect every completion shape, and trapping the user in step 8
// until a specific one happens is the worst possible UX.
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
timeoutMs: 30000,
},
{
kind: 'popup',
text: "Your app is being built! It'll show up shortly — feel free to keep exploring while the agent works.",
},
{ kind: 'delay', ms: 4000 },
{ kind: 'outro' },
],
};
@@ -0,0 +1,70 @@
// Onboarding v2 — step / op / advance-condition schema.
//
// Steps are pure data: a sequence of ACOps (cursor primitives) interleaved
// with wait_user gates that block until an AdvanceCondition fires. The
// runtime in ../ac/acRuntime.ts is the only place that knows how to
// execute these; step files import only this module.
import type { RootState } from '@/shared/state/store';
export type Selector = string; // matches data-onboarding="<v>" or data-select-type="<v>"
export type ACMultiChoiceOption = {
id: string;
label: string;
// Optional branching — if present, picking this option queues additional
// ops to run before the rest of the step's ops continue. Lets one step
// diverge based on user choice without splitting into N steps.
thenOps?: ACOp[];
};
export type ACOp =
| { kind: 'move_to'; target: Selector; offset?: { x: number; y: number } }
| { kind: 'popup'; text: string; cta?: string }
| { kind: 'multi_choice'; opId: string; question: string; options: ACMultiChoiceOption[] }
| { kind: 'highlight_section'; target: Selector; popup?: string; durationMs?: number }
| { kind: 'type_into'; target: Selector; text: string; speedMs?: number }
| { kind: 'click'; target: Selector; simulate?: boolean }
| { kind: 'drag_select'; target: Selector }
| { kind: 'wait_user'; condition: AdvanceCondition; hint?: string; timeoutMs?: number }
| { kind: 'delay'; ms: number }
| { kind: 'outro' };
export type AdvanceCondition =
| { kind: 'click_target'; target: Selector }
| { kind: 'redux_predicate'; selector: (s: RootState) => unknown; equals?: unknown; truthy?: boolean }
| { kind: 'event_bus'; event: string };
export type StepStage = 'get_started' | 'learn_features';
export interface StepDependency {
stepId: string;
reopen: 'walk_again' | 'just_resume';
}
export interface OnboardingStep {
id: string;
stage: StepStage;
index: number; // 1..N (currently 1..8)
title: string;
description: string;
videoSrc?: string;
videoDurationLabel?: string; // e.g. "0:24" — shown in the panel preview chip
ops: ACOp[];
dependsOn?: StepDependency[];
// skipIf is evaluated on launch (and on each Show me click) to mark a step
// already-done without running its flow. Lets existing v1.0.29 users
// upgrade and have already-completed milestones pre-checked.
skipIf?: (state: RootState) => boolean;
// True if the step's ops target dashboard-toolbar elements (+, browser,
// chat input, send, element-selection toggle, apps button). The runtime
// auto-prepends a "click into a dashboard" hop when the user isn't
// already on a #/dashboards/:id route. Without this, every "Show me"
// from the actions/skills/apps pages would hang on a missing target.
requiresDashboard?: boolean;
}
export const STAGE_LABELS: Record<StepStage, string> = {
get_started: 'Get started',
learn_features: 'Learn the features',
};
@@ -0,0 +1,32 @@
// Onboarding v2 telemetry — wraps the existing report() surface so all
// events land under surface='onboarding_v2' (separate from the legacy
// onboarding/walkthrough rows so dashboards stay clean during transition).
//
// Standard properties on every report:
// step_id — current step (or 'panel' / 'roadmap' for non-step events)
// stage — 'get_started' | 'customize'
// ms_since_step — time since the active step started (panel "Show me" click)
// Plus whatever the caller passes in.
import { report as _report } from '@/shared/serviceClient';
let _stepStartTs: number | null = null;
export function markStepStarted(): void {
_stepStartTs = Date.now();
}
export function clearStepTiming(): void {
_stepStartTs = null;
}
export function report(
action: string,
props?: Record<string, unknown>,
): void {
const enriched: Record<string, unknown> = { ...(props ?? {}) };
if (_stepStartTs !== null) {
enriched.ms_since_step = Date.now() - _stepStartTs;
}
_report('onboarding_v2', action, enriched);
}
File diff suppressed because it is too large Load Diff
@@ -1,413 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { report as _report } from '@/shared/serviceClient';
// Same per-step timing wrapper as OnboardingModal — every walkthrough
// report carries `ms_since_start` so the cloud can compute per-step
// dwell time inside the existing aggregation. Reuses the existing
// report() surface; no new outbound paths.
let _walkthroughStartTs: number | null = null;
function report(surface: string, action: string, props?: Record<string, unknown>): void {
if (_walkthroughStartTs === null) _walkthroughStartTs = Date.now();
const enriched: Record<string, unknown> = { ...(props ?? {}) };
enriched["ms_since_start"] = Date.now() - _walkthroughStartTs;
_report(surface, action, enriched);
if (action === "completed") {
_walkthroughStartTs = null;
}
}
export interface WalkthroughStep {
target: string; // data-onboarding="<value>" selector
title: string;
description: string;
placement: 'top' | 'bottom' | 'left' | 'right';
actionHint?: string; // e.g. "Click the + button"
waitForTarget?: boolean; // pause until target appears in DOM
}
const STEPS: WalkthroughStep[] = [
{
target: 'agent-card',
title: 'This is an AI conversation',
description: 'Each card is a chat with AI. You can ask questions, get help writing, research topics, or have it browse the web for you.',
placement: 'right',
actionHint: 'Click it to open',
},
{
target: 'new-agent-button',
title: 'Start a new conversation',
description: 'Click here to create a new AI assistant. You can have multiple conversations running at the same time, side by side.',
placement: 'top',
actionHint: 'Try clicking the + button below',
},
{
target: 'browser-button',
title: 'Browse the web',
description: 'Open a web browser right inside your workspace. Your AI assistants can see and interact with any website.',
placement: 'top',
},
{
target: 'canvas-controls',
title: 'Navigate your workspace',
description: 'Scroll to zoom in and out. Drag the background to pan around. Click any card to focus on it.',
placement: 'top',
},
{
target: 'sidebar-skills',
title: 'Skills',
description: 'Browse and install ready-made workflows \u2014 no coding needed. Skills teach your AI new abilities.',
placement: 'right',
},
{
target: 'sidebar-actions',
title: 'Connect Your Tools',
description: 'Link Google Docs, Notion, Reddit, and more. Your AI assistants can read, write, and interact with your favorite apps.',
placement: 'right',
},
{
target: 'sidebar-modes',
title: 'Assistant Types',
description: 'Customize how your AI behaves. Create specialized assistants for writing, research, coding, or any task.',
placement: 'right',
},
{
target: 'sidebar-apps',
title: 'Build Mini Apps',
description: 'Create simple apps powered by AI \u2014 dashboards, forms, data tools. Just describe what you want.',
placement: 'right',
},
];
interface Props {
onComplete: () => void;
}
interface SpotlightRect {
top: number;
left: number;
width: number;
height: number;
}
const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
const c = useClaudeTokens();
const [currentStep, setCurrentStep] = useState(0);
const [spotlightRect, setSpotlightRect] = useState<SpotlightRect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [visible, setVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number | null>(null);
const step = STEPS[currentStep];
const totalSteps = STEPS.length;
const isLastStep = currentStep === totalSteps - 1;
// Track walkthrough start on mount
useEffect(() => {
report('walkthrough', 'started');
}, []);
// Track each step viewed
useEffect(() => {
if (step) {
report('walkthrough', 'step_viewed', { step: currentStep, step_name: step.target || 'done' });
}
}, [currentStep, step]);
// Find target element and compute spotlight + tooltip position
const updatePosition = useCallback(() => {
if (!step) return;
const el = (
document.querySelector(`[data-onboarding="${step.target}"]`) ||
document.querySelector(`[data-select-type="${step.target}"]`)
) as HTMLElement | null;
if (!el) {
if (step.waitForTarget) {
// Retry next frame
animFrameRef.current = requestAnimationFrame(updatePosition);
return;
}
// Skip this step if target not found
if (currentStep < totalSteps - 1) {
setCurrentStep((s) => s + 1);
}
return;
}
const rect = el.getBoundingClientRect();
const pad = 8;
const sr: SpotlightRect = {
top: rect.top - pad,
left: rect.left - pad,
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
setSpotlightRect(sr);
// Position tooltip relative to spotlight
const tooltipW = 320;
const tooltipH = 180;
const gap = 16;
let tp = { top: 0, left: 0 };
// If target is in the lower half of the screen, anchor the tooltip at a
// fixed center-upper position so it doesn't shift between toolbar steps.
const isBottomTarget = sr.top > window.innerHeight * 0.5;
if (isBottomTarget) {
tp = {
top: Math.round(window.innerHeight * 0.35),
left: Math.round(window.innerWidth / 2 - tooltipW / 2),
};
} else {
switch (step.placement) {
case 'right':
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left + sr.width + gap };
break;
case 'left':
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left - tooltipW - gap };
break;
case 'top':
tp = { top: sr.top - tooltipH - gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
break;
case 'bottom':
tp = { top: sr.top + sr.height + gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
break;
}
}
// Final clamp
tp.left = Math.max(8, Math.min(tp.left, window.innerWidth - tooltipW - 8));
tp.top = Math.max(8, Math.min(tp.top, window.innerHeight - tooltipH - 8));
setTooltipPos(tp);
setVisible(true);
}, [step, currentStep, totalSteps]);
useEffect(() => {
// Don't toggle visibility between steps — that fades the dark overlay out
// and back in, briefly showing the bright dashboard underneath (the "white
// flash"). Just update positions and let the existing CSS transitions
// smoothly animate the spotlight and tooltip to their new locations.
const timer = setTimeout(updatePosition, 0);
return () => {
clearTimeout(timer);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, [currentStep, updatePosition]);
// Recompute on resize
useEffect(() => {
const onResize = () => updatePosition();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [updatePosition]);
const handleNext = useCallback(() => {
if (isLastStep) {
report('walkthrough', 'completed', { steps_viewed: currentStep + 1 });
onComplete();
} else {
setCurrentStep((s) => s + 1);
}
}, [isLastStep, onComplete, currentStep]);
const handleBack = useCallback(() => {
setCurrentStep((s) => Math.max(0, s - 1));
}, []);
// Allow clicking the spotlight target to advance for action steps
useEffect(() => {
if (!step?.actionHint) return;
const el = (
document.querySelector(`[data-onboarding="${step.target}"]`) ||
document.querySelector(`[data-select-type="${step.target}"]`)
) as HTMLElement | null;
if (!el) return;
const handler = () => {
report('walkthrough', 'step_action', { step: currentStep, step_name: step.target });
setTimeout(() => handleNext(), 300);
};
el.addEventListener('click', handler, { once: true });
return () => el.removeEventListener('click', handler);
}, [step, handleNext]);
if (!step) return null;
// SVG mask for spotlight cutout
const clipPath = spotlightRect
? `polygon(
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
${spotlightRect.left}px ${spotlightRect.top}px,
${spotlightRect.left}px ${spotlightRect.top + spotlightRect.height}px,
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top + spotlightRect.height}px,
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top}px,
${spotlightRect.left}px ${spotlightRect.top}px
)`
: undefined;
return (
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 9999,
transition: 'opacity 0.3s ease',
opacity: visible ? 1 : 0,
pointerEvents: 'none',
}}
>
{/* Dark overlay with spotlight cutout — clicks pass through the cutout */}
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.65)',
clipPath: clipPath || 'none',
transition: 'clip-path 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
onClick={handleNext}
/>
{/* Spotlight ring glow */}
{spotlightRect && (
<Box
sx={{
position: 'absolute',
top: spotlightRect.top - 2,
left: spotlightRect.left - 2,
width: spotlightRect.width + 4,
height: spotlightRect.height + 4,
borderRadius: '12px',
border: `2px solid ${c.accent.primary}`,
boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}10`,
pointerEvents: 'none',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
)}
{/* Tooltip card */}
<Box
ref={tooltipRef}
sx={{
position: 'absolute',
top: tooltipPos.top,
left: tooltipPos.left,
width: 320,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
p: 2.5,
overflow: 'hidden',
transition: 'top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s',
opacity: visible ? 1 : 0,
pointerEvents: 'auto',
zIndex: 10000,
}}
>
{/* Step counter dots */}
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.5, justifyContent: 'center' }}>
{STEPS.map((_, i) => (
<Box
key={i}
sx={{
width: i === currentStep ? 16 : 5,
height: 5,
borderRadius: 3,
bgcolor: i === currentStep ? c.accent.primary : i < currentStep ? c.accent.primary + '60' : c.border.medium,
transition: 'all 0.3s',
}}
/>
))}
</Box>
<Typography
sx={{
fontSize: '1rem',
fontWeight: 700,
color: c.text.primary,
mb: 0.75,
fontFamily: c.font.sans,
}}
>
{step.title}
</Typography>
<Typography
sx={{
fontSize: '0.82rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: step.actionHint ? 1 : 2,
fontFamily: c.font.sans,
}}
>
{step.description}
</Typography>
{step.actionHint && (
<Typography
sx={{
fontSize: '0.72rem',
color: c.accent.primary,
fontWeight: 600,
mb: 2,
fontFamily: c.font.sans,
}}
>
{step.actionHint}
</Typography>
)}
{/* Buttons */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button
onClick={handleBack}
disabled={currentStep === 0}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
color: c.text.tertiary,
borderRadius: `${c.radius.md}px`,
px: 2,
py: 0.75,
fontFamily: c.font.sans,
visibility: currentStep === 0 || step.target === 'new-agent-button' ? 'hidden' : 'visible',
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
Back
</Button>
<Button
onClick={handleNext}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.75,
fontFamily: c.font.sans,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
{isLastStep ? 'Get Started' : 'Next'}
</Button>
</Box>
</Box>
</Box>
);
};
export default OnboardingWalkthrough;
+425 -72
View File
@@ -1,59 +1,70 @@
// Sign-in gate. Shown post-onboarding to users without an active identity:
// - User signed out from Settings, gate appears so they can sign back in.
// - Existing v1.0.28 user upgrading to v1.0.29 with no user_id yet (the
// soft-gate grace window applies; see SignInGateLoader in Main.tsx).
// Mandatory sign-in gate. Two paths to identity:
//
// Fresh first-launch users go through OnboardingModal instead — its first
// step is sign-in, this gate stays hidden in that path.
// 1. Continue with Google → cloud OAuth handoff (existing).
// Opens https://api.openswarm.com/api/auth/google/start in the OS
// browser; the cloud's bearer-handoff page POSTs the bearer back to
// this desktop's local /api/auth/signin-activate. settings.user_id
// flips non-null and the gate self-dismisses (SignInGateLoader's
// poll picks up the change within ~2s).
//
// Path: "Continue with Google" → shell.openExternal opens the cloud's
// /api/auth/google/start. Cloud handles the round-trip and serves a
// bearer-handoff page that POSTs the bearer to the local backend's
// /api/auth/signin-activate. After the bearer lands, settings.user_id
// flips non-null and the gate self-dismisses (SignInGateLoader's poll
// picks up the change within ~2s).
// 2. Email + password (new in v2). Two-stage:
// - Stage 1: user enters email + password, we POST /api/auth/email/start
// on the cloud. Cloud bcrypts the password, mints a 6-digit code,
// stores hash in email_verifications, sends it via Resend.
// - Stage 2: user pastes the code, we POST /api/auth/email/verify.
// On success the cloud upserts the users row (sets password_hash),
// mints a bearer with source='email', returns the same handoff
// shape as Google, and the desktop's existing signin-activate
// path takes it from there.
//
// No "Skip for now" — sign-in is mandatory in v2. Users without a Google
// email can use the email/password path instead.
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Typography,
Modal,
Button,
TextField,
CircularProgress,
Link,
} from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google';
import EmailIcon from '@mui/icons-material/Email';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
import { OPENSWARM_DEFAULT_PROXY_URL, API_BASE } from '@/shared/config';
import { report } from '@/shared/serviceClient';
interface SignInGateProps {
/** Soft gate adds a "Skip for now" link; hard gate omits it. */
softGate: boolean;
onSkip?: () => void;
}
type Stage = 'choose' | 'email_form' | 'code_form';
export default function SignInGate({ softGate, onSkip }: SignInGateProps): JSX.Element {
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
export default function SignInGate(): JSX.Element {
const tokens = useClaudeTokens();
const proxyUrl = useAppSelector(
(s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL,
);
const installId = useAppSelector((s) => s.settings.data.installation_id ?? '');
const [stage, setStage] = useState<Stage>('choose');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [errMsg, setErrMsg] = useState<string | null>(null);
const cloudBase = proxyUrl.replace(/\/$/, '');
const onGoogle = () => {
report('signin', 'google_clicked');
// Pass local_port so the cloud's bearer-handoff page POSTs to this
// exact backend port. Without it, the page falls back to probing
// 8324..8328 — which fails for users whose machines have those ports
// occupied (Electron picks the first free port in 8324..8424).
const localPort = (window as any).__OPENSWARM_PORT__ || 8324;
const params = new URLSearchParams({
install_id: installId,
local_port: String(localPort),
});
const startUrl =
proxyUrl.replace(/\/$/, '') +
'/api/auth/google/start?' + params.toString();
const startUrl = `${cloudBase}/api/auth/google/start?${params.toString()}`;
const api = (window as any).openswarm;
if (api?.openExternal) {
api.openExternal(startUrl);
@@ -62,10 +73,179 @@ export default function SignInGate({ softGate, onSkip }: SignInGateProps): JSX.E
}
};
const onSubmitEmailPassword = async () => {
setErrMsg(null);
if (!EMAIL_REGEX.test(email.trim())) {
setErrMsg('Enter a valid email address.');
return;
}
if (password.length < 8) {
setErrMsg('Password must be at least 8 characters.');
return;
}
setBusy(true);
// Distinguishes "endpoint doesn't exist on this cloud build" from
// "real auth failure". 404 covers production-cloud-not-yet-deployed;
// a thrown fetch typically means CORS preflight rejected (also
// production-cloud-not-yet-deployed, since the route isn't registered).
const EMAIL_UNAVAILABLE_MSG =
"Email sign-in isn't available on this build yet. Please use Continue with Google for now, or update OpenSwarm.";
let loginRes: Response | null = null;
try {
report('signin', 'email_login_attempted');
loginRes = await fetch(`${cloudBase}/api/auth/email/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.trim(),
password,
install_id: installId,
}),
});
} catch (err) {
// fetch threw — almost always CORS / network. Treat as endpoint
// unavailable and show the friendly message.
report('signin', 'email_endpoint_unreachable', { phase: 'login', err: String(err) });
setErrMsg(EMAIL_UNAVAILABLE_MSG);
setBusy(false);
return;
}
try {
if (loginRes.ok) {
const data = (await loginRes.json()) as {
bearer?: string;
user_id?: string;
user_email?: string;
};
if (!data.bearer) throw new Error('Server did not return a bearer.');
const activate = await fetch(`${API_BASE}/auth/signin-activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.bearer,
email: data.user_email,
signin_method: 'email',
}),
});
if (!activate.ok) {
const text = await activate.text().catch(() => '');
throw new Error(text || `Local activate failed (${activate.status})`);
}
report('signin', 'email_login_succeeded');
return;
}
if (loginRes.status === 401) {
setErrMsg('Incorrect email or password.');
report('signin', 'email_login_rejected');
return;
}
if (loginRes.status === 404) {
// 404 from /login = either no account (first-time signup) OR
// the cloud doesn't ship this endpoint yet. Try /start; if that
// also 404s (or throws), the cloud build is out-of-date and we
// surface the friendly message.
} else {
// 5xx / unexpected. Surface a generic retry hint, not the raw text.
report('signin', 'email_login_unexpected', { status: loginRes.status });
setErrMsg("Couldn't sign you in right now. Try again in a moment.");
return;
}
report('signin', 'email_start_submitted');
let startRes: Response;
try {
startRes = await fetch(`${cloudBase}/api/auth/email/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
} catch (err) {
report('signin', 'email_endpoint_unreachable', { phase: 'start', err: String(err) });
setErrMsg(EMAIL_UNAVAILABLE_MSG);
return;
}
if (startRes.status === 404) {
report('signin', 'email_endpoint_not_deployed');
setErrMsg(EMAIL_UNAVAILABLE_MSG);
return;
}
if (!startRes.ok) {
const text = await startRes.text().catch(() => '');
report('signin', 'email_start_failed', { status: startRes.status });
setErrMsg(text || "Couldn't send the code. Try again.");
return;
}
setStage('code_form');
} catch (err) {
setErrMsg(`Couldn't sign you in. ${(err as Error).message || 'Try again.'}`);
} finally {
setBusy(false);
}
};
const onSubmitCode = async () => {
setErrMsg(null);
if (!/^\d{6}$/.test(code)) {
setErrMsg('Enter the 6-digit code from your email.');
return;
}
setBusy(true);
try {
report('signin', 'email_verify_submitted');
const localPort = (window as any).__OPENSWARM_PORT__ || 8324;
const res = await fetch(`${cloudBase}/api/auth/email/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.trim(),
code,
install_id: installId,
local_port: localPort,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const data = (await res.json()) as { bearer?: string; user_id?: string; user_email?: string };
if (!data.bearer) throw new Error('Server did not return a bearer.');
// Hand the bearer to the local backend the same way Google's
// handoff page does, so the rest of the app converges identically.
const activate = await fetch(`${API_BASE}/auth/signin-activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.bearer,
email: data.user_email,
signin_method: 'email',
}),
});
if (!activate.ok) {
const text = await activate.text().catch(() => '');
throw new Error(text || `Local activate failed (${activate.status})`);
}
// SignInGateLoader's polling picks up the new user_id within 2s
// and unmounts this gate. Nothing else to do.
} catch (err) {
setErrMsg((err as Error).message || 'Verification failed.');
} finally {
setBusy(false);
}
};
const onResendCode = async () => {
setStage('email_form');
setCode('');
setErrMsg(null);
};
return (
<Modal
open
disableEscapeKeyDown={!softGate}
disableEscapeKeyDown
hideBackdrop={false}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
slotProps={{ backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.55)' } } }}
@@ -84,51 +264,224 @@ export default function SignInGate({ softGate, onSkip }: SignInGateProps): JSX.E
outline: 'none',
}}
>
<Typography
variant="h5"
sx={{ fontFamily: '"Charter", Georgia, serif', fontWeight: 500, mb: 1 }}
>
Sign in to OpenSwarm
</Typography>
<Typography
variant="body2"
sx={{ color: tokens.text.muted, mb: 3, lineHeight: 1.5 }}
>
Sign in lets us sync your settings and back up your data.
</Typography>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<GoogleIcon />}
onClick={onGoogle}
sx={{
py: 1.4,
backgroundColor: tokens.text.primary,
color: tokens.text.inverse,
textTransform: 'none',
fontSize: 15,
fontWeight: 500,
'&:hover': { backgroundColor: tokens.text.primary, opacity: 0.9 },
}}
>
Continue with Google
</Button>
{softGate && onSkip && (
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${tokens.border.subtle}` }}>
<Link
component="button"
onClick={() => {
report('signin', 'gate_skipped');
onSkip();
}}
sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }}
{stage === 'code_form' ? (
<>
<Typography
variant="h5"
sx={{ fontFamily: '"Charter", Georgia, serif', fontWeight: 500, mb: 1 }}
>
Skip for now I'll sign in later
</Link>
</Box>
Check your inbox
</Typography>
<Typography
variant="body2"
sx={{ color: tokens.text.muted, mb: 3, lineHeight: 1.5 }}
>
We emailed a 6-digit code to{' '}
<Box component="span" sx={{ color: tokens.text.primary, fontWeight: 500 }}>
{email}
</Box>
.
</Typography>
<TextField
fullWidth
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputProps={{
inputMode: 'numeric',
maxLength: 6,
style: {
textAlign: 'center',
letterSpacing: '0.4em',
fontFamily: 'ui-monospace, monospace',
fontSize: 22,
fontWeight: 600,
},
}}
placeholder="••••••"
disabled={busy}
sx={{ mb: 2 }}
/>
{errMsg && (
<Typography
sx={{ color: tokens.status.error, fontSize: 13, mb: 1.5 }}
>
{errMsg}
</Typography>
)}
<Button
fullWidth
variant="contained"
size="large"
onClick={onSubmitCode}
disabled={busy || code.length !== 6}
sx={{
py: 1.4,
backgroundColor: tokens.accent.primary,
color: '#fff',
textTransform: 'none',
fontSize: 15,
fontWeight: 600,
'&:hover': { backgroundColor: tokens.accent.primary, opacity: 0.9 },
}}
>
{busy ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : 'Verify →'}
</Button>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center', gap: 2 }}>
<Link
component="button"
onClick={onResendCode}
sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }}
>
Resend code
</Link>
<Link
component="button"
onClick={() => {
setStage('choose');
setEmail('');
setPassword('');
setCode('');
setErrMsg(null);
}}
sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }}
>
Use a different email
</Link>
</Box>
</>
) : (
<>
<Typography
variant="h5"
sx={{ fontFamily: '"Charter", Georgia, serif', fontWeight: 500, mb: 1 }}
>
Sign in to OpenSwarm
</Typography>
<Typography
variant="body2"
sx={{ color: tokens.text.muted, mb: 3, lineHeight: 1.5 }}
>
Sign in lets us sync your settings and back up your data.
</Typography>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<GoogleIcon />}
onClick={onGoogle}
sx={{
py: 1.4,
backgroundColor: tokens.text.primary,
color: tokens.text.inverse,
textTransform: 'none',
fontSize: 15,
fontWeight: 500,
'&:hover': { backgroundColor: tokens.text.primary, opacity: 0.9 },
}}
>
Continue with Google
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
color: tokens.text.muted,
fontSize: 12,
}}
>
<Box sx={{ flex: 1, height: 1, bgcolor: tokens.border.subtle }} />
or
<Box sx={{ flex: 1, height: 1, bgcolor: tokens.border.subtle }} />
</Box>
{stage === 'choose' ? (
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<EmailIcon />}
onClick={() => setStage('email_form')}
sx={{
py: 1.4,
borderColor: tokens.border.medium,
color: tokens.text.primary,
textTransform: 'none',
fontSize: 15,
fontWeight: 500,
'&:hover': { borderColor: tokens.text.primary },
}}
>
Continue with email
</Button>
) : (
<Box sx={{ textAlign: 'left' }}>
<TextField
fullWidth
autoFocus
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
sx={{ mb: 1.5 }}
size="small"
/>
<TextField
fullWidth
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
helperText="At least 8 characters."
sx={{ mb: 1.5 }}
size="small"
/>
{errMsg && (
<Typography
sx={{ color: tokens.status.error, fontSize: 13, mb: 1.2 }}
>
{errMsg}
</Typography>
)}
<Button
fullWidth
variant="contained"
size="large"
onClick={onSubmitEmailPassword}
disabled={busy}
sx={{
py: 1.3,
backgroundColor: tokens.accent.primary,
color: '#fff',
textTransform: 'none',
fontSize: 14.5,
fontWeight: 600,
'&:hover': { backgroundColor: tokens.accent.primary, opacity: 0.9 },
}}
>
{busy ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : 'Send code →'}
</Button>
<Box sx={{ mt: 1.5, textAlign: 'center' }}>
<Link
component="button"
onClick={() => {
setStage('choose');
setErrMsg(null);
}}
sx={{ fontSize: 12, color: tokens.text.muted, textDecoration: 'none' }}
>
Back
</Link>
</Box>
</Box>
)}
</>
)}
</Box>
</Modal>
@@ -35,6 +35,7 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker';
import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard';
import { getWebview } from '@/shared/browserRegistry';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -803,6 +804,18 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
let trimmed = serialized.trim();
if (!trimmed) return;
// Onboarding bus signal — step 3 (launch agent), step 5/6 (agent uses
// browser / agent controls agents), step 8 (make an App) all wait for
// the user to actually send a message before the cursor advances.
onboardingBus.emit('chat:message_sent');
// The App Builder chat lives inside ViewEditor (`/apps/new`) — when
// the user submits there, the underlying agent generates an app.
// Surface this as app:generation_started so step 8 can advance from
// its typing op to its "wait for app to land" op.
if (window.location.hash.includes('/apps/')) {
onboardingBus.emit('app:generation_started');
}
// Slash commands (Phase 2). Parsed client-side so we don't pollute
// the agent loop with meta-actions; calls the corresponding backend
// endpoint and clears the input. /context is pure-frontend (toggle
@@ -1411,6 +1424,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
<Box sx={{ px: 1.5, pt: hasAttachments ? 0.5 : 1.25, pb: 0.25, position: 'relative' }}>
<div
ref={editorRef}
data-onboarding="chat-input"
contentEditable={!disabled}
suppressContentEditableWarning
spellCheck
@@ -2189,6 +2203,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
<IconButton
size="small"
onMouseDown={(e) => e.preventDefault()}
data-onboarding="element-selection-toggle"
onClick={() => {
if (isMySelectMode) {
elementSelection.setSelectMode(false);
@@ -2267,6 +2282,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
size="small"
onClick={handleSend}
disabled={disabled}
data-onboarding="chat-send-button"
sx={{
bgcolor: c.accent.primary,
color: c.text.inverse,
+13 -1
View File
@@ -615,7 +615,19 @@ const AgentCard: React.FC<Props> = ({
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
// Onboarding tiebreaker: when the user has multiple agent cards open
// (e.g. step 5 leaves the YouTube-summary agent on canvas while
// step 6 spawns a new orchestrator), per-agent selectors like
// chat-input need a way to identify the NEWEST card. Object.values
// iteration order in Dashboard.tsx is keyed by session.id and not
// monotonic by creation time, so DOM order can't be trusted.
// ISO date parses cleanly to ms; missing values fall through to the
// last-DOM-node fallback in resolveSelector.
data-onboarding-spawn-ms={
session.created_at
? new Date(session.created_at).getTime() || undefined
: undefined
}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(session.id, 'agent', e.shiftKey);
+9 -17
View File
@@ -60,7 +60,9 @@ import NoteCard from './NoteCard';
import CanvasControls from './CanvasControls';
import CardSearchPalette from './CardSearchPalette';
import DirectionHints from './DirectionHints';
import OnboardingWalkthrough from '@/app/components/OnboardingWalkthrough';
// OnboardingWalkthrough was retired in v2 — the new OnboardingRoot/Panel
// (mounted in Main.tsx) replaces it. Keeping this banner to prevent stale
// imports from sneaking back in via auto-completion.
import DashboardToolbar from './DashboardToolbar';
import { captureDashboardThumbnail } from './captureDashboardThumbnail';
import { useCanvasControls } from './useCanvasControls';
@@ -160,19 +162,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
const [pendingSelectSessionId, setPendingSelectSessionId] = useState<string | null>(null);
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const [showWalkthrough, setShowWalkthrough] = useState(() => {
if (localStorage.getItem('openswarm_walkthrough_pending') === 'true') {
return true;
}
return false;
});
const [newAgentBounce, setNewAgentBounce] = useState(false);
const handleWalkthroughComplete = useCallback(() => {
setShowWalkthrough(false);
localStorage.removeItem('openswarm_walkthrough_pending');
localStorage.setItem('openswarm_walkthrough_seen', 'true');
setNewAgentBounce(true);
// Cleanup any leftover walkthrough localStorage from v1 — the v2 panel
// ignores it but it would otherwise hang around forever.
useEffect(() => {
try {
localStorage.removeItem('openswarm_walkthrough_pending');
} catch { /* ignore */ }
}, []);
const handleHighlightCard = useCallback((cardId: string) => {
@@ -2136,10 +2132,6 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
sessions={sessions}
/>
{/* Onboarding walkthrough overlay */}
{showWalkthrough && (
<OnboardingWalkthrough onComplete={handleWalkthroughComplete} />
)}
</>
);
};
@@ -408,7 +408,16 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}}
>
{inputOpen ? (
<div style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}>
// data-onboarding-scope="dock" lets the AC's per-agent-selector
// resolver prefer this chat input (the new-agent dock that
// appears after clicking +) over any existing agent-card's
// chat input. Without this, AC would route to the most
// recently-spawned agent-card, which is usually the wrong
// target on step 5/6 (where the "new agent" is the dock draft).
<div
data-onboarding-scope="dock"
style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}
>
<ChatInput
onSend={handleSend}
mode={mode}
@@ -695,6 +704,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
aria-label="Add View"
tabIndex={0}
onClick={handleOpenViewPicker}
data-onboarding="dashboard-toolbar-apps"
sx={{
display: 'flex',
alignItems: 'center',
@@ -443,56 +443,108 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
animateTo({ panX: newPanX, panY: newPanY, zoom: newZoom });
}, [animateTo]);
const fitToCards = useCallback((cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean, minZoom?: number) => {
cancelAnimation();
// Pure target computation — extracted so we can re-run it after the
// animation settles and detect viewport-rect drift mid-flight (sidebar
// collapse, route switch, panel mount/unmount, etc). Returns null if
// the viewport is missing or the rect set is empty.
const computeFitTarget = useCallback(
(
cardRects: Array<{ x: number; y: number; width: number; height: number }>,
maxZoom?: number,
minZoom?: number,
): { panX: number; panY: number; zoom: number } | null => {
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) return null;
const vRect = viewport.getBoundingClientRect();
if (vRect.width <= 0 || vRect.height <= 0) return null;
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) {
setState({ panX: 0, panY: 0, zoom: 1 });
return;
}
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const card of cardRects) {
minX = Math.min(minX, card.x);
minY = Math.min(minY, card.y);
maxX = Math.max(maxX, card.x + card.width);
maxY = Math.max(maxY, card.y + card.height);
}
if (!isFinite(minX)) return null;
const vRect = viewport.getBoundingClientRect();
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const ceiling = maxZoom ?? MAX_ZOOM;
const floor = minZoom ?? MIN_ZOOM;
const targetZoom = clamp(
Math.min(availW / contentWidth, availH / contentHeight),
floor,
ceiling,
);
const targetPanX =
(vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
const topBiased = cardRects.length === 1;
const targetPanY = topBiased
? FIT_PADDING * 0.4 - minY * targetZoom
: (vRect.height - contentHeight * targetZoom) / 2 -
minY * targetZoom;
return { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
},
[],
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const card of cardRects) {
minX = Math.min(minX, card.x);
minY = Math.min(minY, card.y);
maxX = Math.max(maxX, card.x + card.width);
maxY = Math.max(maxY, card.y + card.height);
}
const fitToCards = useCallback(
(
cardRects: Array<{ x: number; y: number; width: number; height: number }>,
maxZoom?: number,
animate?: boolean,
minZoom?: number,
) => {
cancelAnimation();
if (!isFinite(minX)) {
setState({ panX: 0, panY: 0, zoom: 1 });
return;
}
const target = computeFitTarget(cardRects, maxZoom, minZoom);
if (!target) {
// Viewport unavailable / no content — keep current camera, don't
// snap to (0,0,1) which used to leave the minimap thinking it
// was centered when the canvas was anywhere.
if (cardRects.length === 0 || !viewportRef.current) {
setState({ panX: 0, panY: 0, zoom: 1 });
}
return;
}
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const ceiling = maxZoom ?? MAX_ZOOM;
const floor = minZoom ?? MIN_ZOOM;
const targetZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), floor, ceiling);
const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
// For single cards, position near top of viewport (80px padding) instead of dead center
const topBiased = cardRects.length === 1;
const targetPanY = topBiased
? (FIT_PADDING * 0.4) - minY * targetZoom
: (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
const target = { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
if (animate) {
// Skip if already at target (avoids jitter on re-click)
const cur = stateRef.current;
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
} else {
setState(target);
}
}, [cancelAnimation, animateTo]);
if (animate) {
const cur = stateRef.current;
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
// Settle pass — re-run the math one frame after the animation
// ends and snap-correct any drift from viewport changes during
// the flight (sidebar collapse, route switch, etc). Without
// this, the camera lands on stale-target coords while the
// minimap reads the current panX/panY, producing the visible
// mismatch the user reported. ~370ms = animation length (320)
// + one rAF settle. Cheap: a single getBoundingClientRect +
// potential setState if drift > threshold.
window.setTimeout(() => {
const fresh = computeFitTarget(cardRects, maxZoom, minZoom);
if (!fresh) return;
const cur2 = stateRef.current;
const drift =
Math.abs(cur2.panX - fresh.panX) +
Math.abs(cur2.panY - fresh.panY) +
Math.abs(cur2.zoom - fresh.zoom) * 1000;
// 8px-equivalent drift threshold — anything below is invisible
// to the user and not worth a snap that could itself jitter.
if (drift > 8) setState(fresh);
}, 370);
} else {
setState(target);
}
},
[cancelAnimation, animateTo, computeFitTarget],
);
const handlers = useMemo(() => ({
onMouseDown: handleMouseDown,
+73 -22
View File
@@ -42,6 +42,8 @@ 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 { onboardingBus } from '@/app/components/Onboarding/eventBus';
import { resetTour } from '@/app/components/Onboarding/OnboardingProgressSlice';
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
import { fetchModels } from '@/shared/state/modelsSlice';
import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice';
@@ -1436,6 +1438,7 @@ const Settings: React.FC = () => {
setConfirmDiscard(true);
} else {
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}
}, [hasChanges, dispatch]);
@@ -1443,6 +1446,7 @@ const Settings: React.FC = () => {
setConfirmDiscard(false);
setForm({ ...settings });
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}, [settings, dispatch]);
const handleSaveAndClose = useCallback(async () => {
@@ -1454,6 +1458,7 @@ const Settings: React.FC = () => {
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}, [dispatch, form, settings, setThemeMode]);
const fieldSx = {
@@ -1537,7 +1542,7 @@ const Settings: React.FC = () => {
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
Settings
</Typography>
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<IconButton onClick={handleRequestClose} size="small" data-onboarding="settings-close-button" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
@@ -1559,7 +1564,7 @@ const Settings: React.FC = () => {
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple />
<Tab label="Models" value="models" disableRipple data-onboarding="settings-models-tab" />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
@@ -2132,40 +2137,85 @@ const Settings: React.FC = () => {
)}
</Box>
{/* Restart the onboarding tour. Wipes local progress so the
Get-Started panel re-opens at step 1 with no completed steps. */}
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Re-run the Show me walkthrough at any time.
</Typography>
</Box>
<Button
variant="outlined"
size="small"
data-onboarding="settings-restart-tour"
onClick={() => {
report('onboarding_v2', 'tour_restarted');
try {
window.localStorage.removeItem('openswarm.onboarding.v2');
} catch { /* ignore */ }
// Soft reset via Redux — wipes completedSteps, opens the
// expanded panel at step 1. No reload needed; the slice's
// resetTour reducer handles everything in-memory and the
// localStorage-mirror middleware re-persists the new state.
dispatch(resetTour());
// Close the settings modal so the user sees the panel.
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}}
sx={{
color: c.text.secondary,
borderColor: c.border.medium,
textTransform: 'none',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
}}
>
Restart tour
</Button>
</Box>
</Box>
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── OPENSWARM PRO (managed) ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
One Subscription, No Setup
</Typography>
<Box data-onboarding="settings-pro-section" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
One Subscription, No Setup
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku.
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku.
</Typography>
<OpenSwarmProCard />
<OpenSwarmProCard />
</Box>
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Use Your Existing Subscriptions
</Typography>
<Box data-onboarding="settings-external-subs" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
</Typography>
<SubscriptionCards />
<SubscriptionCards />
</Box>
{/* ── API KEYS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Box data-onboarding="settings-api-keys" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
{/* Anthropic */}
<Box>
@@ -2573,6 +2623,7 @@ const Settings: React.FC = () => {
</Button>
</Box>
</Box>
</Box>
</Box>
) : activeTab === 'usage' ? (
@@ -265,6 +265,7 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
<Tooltip title="Build skill with AI" placement="left">
<Fab
onClick={() => setExpanded(true)}
data-onboarding="skill-builder-fab"
sx={{
position: 'absolute',
bottom: 24,
+9 -1
View File
@@ -50,6 +50,7 @@ import {
RegistrySkill,
RegistrySkillDetail,
} from '@/shared/state/skillRegistrySlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat';
interface SkillForm {
@@ -189,6 +190,7 @@ const Skills: React.FC = () => {
content: selectedReg.content,
command: selectedReg.name.toLowerCase().replace(/\s+/g, '-'),
}));
onboardingBus.emit('skill:installed');
setSnackbar({ open: true, message: `Installed "${selectedReg.name}" as a local skill` });
};
@@ -282,9 +284,11 @@ const Skills: React.FC = () => {
selected: boolean;
onClick: () => void;
icon?: React.ReactNode;
}> = ({ label, selected, onClick, icon }) => (
onboardingId?: string;
}> = ({ label, selected, onClick, icon, onboardingId }) => (
<Box
onClick={onClick}
data-onboarding={onboardingId}
sx={{
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.6,
borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
@@ -463,6 +467,9 @@ const Skills: React.FC = () => {
label={sk.name}
selected={isSelected('registry', sk.name)}
onClick={() => selectRegistry(sk.name)}
onboardingId={
/pdf/i.test(sk.name) ? 'skill-item-pdf' : undefined
}
/>
))}
</Box>
@@ -551,6 +558,7 @@ const Skills: React.FC = () => {
size="small"
startIcon={<DownloadIcon sx={{ fontSize: 15 }} />}
onClick={handleInstall}
data-onboarding="skill-install-button"
sx={{
bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none', borderRadius: `${c.radius.md}px`, px: 2, py: 0.5,
+43 -5
View File
@@ -1405,7 +1405,10 @@ const Tools: React.FC = () => {
{uninstalledIntegrations.map((ig) => {
const isLoading = !!integrationLoading[ig.id];
return (
<Card key={ig.id} sx={{ order: 2, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<Card
key={ig.id}
sx={{ order: 2, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, transition: 'border-color 0.2s, box-shadow 0.2s' }}
>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{
@@ -1421,7 +1424,10 @@ const Tools: React.FC = () => {
</Box>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{ig.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Box
data-onboarding={ig.id === 'reddit' ? 'actions-reddit-toggle' : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}
>
{isLoading && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={false}
@@ -1494,10 +1500,21 @@ const Tools: React.FC = () => {
const allNames = [...(data.read || []), ...(data.write || [])];
const svcPolicy = getGroupPolicy(allNames);
const count = allNames.length;
// Same defensive Reddit check as the outer Card — if the
// Integration lookup didn't find a match we still want
// these subreddits/permission selectors to attach so
// step 2's chevron + permission ops resolve.
const isReddit =
ig?.id === 'reddit' ||
tool.name?.toLowerCase() === 'reddit' ||
(tool.command || '').toLowerCase().includes('reddit');
const isSubredditsForReddit =
isReddit && /subreddit/i.test(serviceName);
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', '&:hover': { borderColor: `${c.border.medium}` } }}>
<Box
data-onboarding={isSubredditsForReddit ? 'actions-subreddits-chevron' : undefined}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 0.75, cursor: 'pointer', bgcolor: isOpen ? c.bg.secondary : 'transparent', '&:hover': { bgcolor: c.bg.secondary } }}
onClick={() => setExpandedServices((p) => ({ ...p, [svcKey]: !isOpen }))}
>
@@ -1506,7 +1523,9 @@ const Tools: React.FC = () => {
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>{serviceName}</Typography>
<Chip label={count} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<PermToggle value={svcPolicy === 'mixed' ? 'ask' : svcPolicy} onChange={(v) => handleGroupPermissionChange(tool.id, allNames, v)} />
<Box data-onboarding={isSubredditsForReddit ? 'actions-permission-toggle' : undefined}>
<PermToggle value={svcPolicy === 'mixed' ? 'ask' : svcPolicy} onChange={(v) => handleGroupPermissionChange(tool.id, allNames, v)} />
</Box>
</Box>
<Collapse in={isOpen}>
<Box sx={{ px: 1, pb: 1 }}>
@@ -1602,11 +1621,26 @@ const Tools: React.FC = () => {
const isDisabled = tool.enabled === false;
// Defensive Reddit detection: ig.id is the canonical key but
// depends on Integration metadata matching tool.name exactly.
// If a tool was installed under a different name shape (e.g.
// legacy install, manual MCP add), the lookup fails and
// ig?.id === 'reddit' is false. Fall back to tool.name and
// tool.command lowercase checks so the data-onboarding hooks
// still attach and onboarding click_target waits can resolve.
const isReddit =
ig?.id === 'reddit' ||
tool.name?.toLowerCase() === 'reddit' ||
(tool.command || '').toLowerCase().includes('reddit');
return (
<Card key={tool.id} sx={{ order: tool.auth_status === 'connected' ? 0 : 1, bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<Card
key={tool.id}
sx={{ order: tool.auth_status === 'connected' ? 0 : 1, bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}
>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, cursor: isDisabled ? 'default' : 'pointer' }}
data-onboarding={isReddit ? 'actions-reddit-chevron' : undefined}
onClick={() => !isDisabled && setExpandedToolId(isExpanded ? null : tool.id)}
>
{ig && (
@@ -1684,7 +1718,11 @@ const Tools: React.FC = () => {
</Tooltip>
)}
{ig && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Box
data-onboarding={isReddit ? 'actions-reddit-toggle' : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
>
{!!integrationLoading[ig.id] && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={tool.enabled !== false}
+25 -9
View File
@@ -41,6 +41,7 @@ import CodeEditor from './CodeEditor';
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
import { captureViewThumbnail } from './captureViewThumbnail';
import { API_BASE } from '@/shared/config';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
const POLL_INTERVAL_MS = 2000;
@@ -868,6 +869,11 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
savedId = created.id;
createdIdRef.current = savedId;
setCreatedId(savedId);
// First successful create = the App Builder agent finished
// generating an app. Step 8's "wait for app to land" listens for
// this. Subsequent saves don't fire — only the initial creation
// matters for onboarding.
onboardingBus.emit('app:generation_done');
}
savedRef.current = true;
setSaveStatus('saved');
@@ -1144,6 +1150,13 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
<Box sx={{ height: '100%', display: 'flex', overflow: 'hidden' }}>
{/* Left panel — AgentChat */}
<Box
// data-onboarding-scope="app-builder" — the AC's per-agent
// selector resolver prefers this scope when it's mounted, so
// step 8's chat-input / chat-send-button / type_into all
// resolve inside the App Builder's AgentChat instance instead
// of falling through to whatever chat-input was last in DOM
// order (which led to AC typing into nothing visible).
data-onboarding-scope="app-builder"
sx={{
width: sidebarWidth,
flexShrink: 0,
@@ -1284,6 +1297,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
onClick={() => handleSave(false)}
disabled={saving || !name.trim()}
size="small"
data-onboarding="app-builder-submit"
sx={{
bgcolor: c.accent.primary,
textTransform: 'none',
@@ -1626,15 +1640,17 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem', lineHeight: 1.6, flexShrink: 0 }}>
Describe what data to generate for this app. When triggered, an LLM will produce input data matching your schema and populate the preview.
</Typography>
<ChatInput
ref={autoRunInputRef}
autoRunMode
onSend={() => {}}
mode={autoRunMode}
onModeChange={setAutoRunMode}
model={autoRunModel}
onModelChange={setAutoRunModel}
/>
<Box data-onboarding="app-builder-input" sx={{ width: '100%' }}>
<ChatInput
ref={autoRunInputRef}
autoRunMode
onSend={() => {}}
mode={autoRunMode}
onModeChange={setAutoRunMode}
model={autoRunModel}
onModelChange={setAutoRunModel}
/>
</Box>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon sx={{ fontSize: 16 }} />}
+12 -7
View File
@@ -101,6 +101,7 @@ const Views: React.FC = () => {
variant="contained"
startIcon={<AddIcon />}
onClick={handleNewView}
data-onboarding="apps-new-button"
sx={{
bgcolor: c.accent.primary,
borderRadius: 2,
@@ -144,14 +145,18 @@ const Views: React.FC = () => {
gap: 2.5,
}}
>
{outputs.map((output) => (
<ViewCard
{outputs.map((output, idx) => (
<Box
key={output.id}
output={output}
onClick={() => handleEditView(output)}
onDelete={() => handleDeleteView(output.id)}
onRun={() => setRunOutput(output)}
/>
data-onboarding={idx === 0 ? 'app-card-latest' : undefined}
>
<ViewCard
output={output}
onClick={() => handleEditView(output)}
onDelete={() => handleDeleteView(output.id)}
onRun={() => setRunOutput(output)}
/>
</Box>
))}
</Box>
)}
@@ -0,0 +1,54 @@
// Window blur/focus tracking — analytics signal for "user switched to
// another app" (temp-churn measurement).
//
// Wires the IPC channel that electron/main.js fires on the BrowserWindow's
// blur/focus events into the existing `report()` analytics pipeline. Each
// blur emits `app focus_lost` with the elapsed-ms-since-last-focus, and
// each focus emits `app focus_gained` with elapsed-ms-since-last-blur.
//
// Together these answer: how often do users leave OpenSwarm mid-session,
// for how long, and at what cadence?
//
// No-op in browser/web context where window.openswarm is undefined.
import { useEffect } from 'react';
import { report } from '@/shared/serviceClient';
interface FocusPayload {
kind: 'blur' | 'focus';
ts: number;
}
interface OpenSwarmAPI {
onWindowFocus?: (cb: (payload: FocusPayload) => void) => () => void;
}
export function useWindowFocus(): void {
useEffect(() => {
const api = (window as unknown as { openswarm?: OpenSwarmAPI }).openswarm;
if (!api?.onWindowFocus) return;
let lastBlurTs: number | null = null;
let lastFocusTs: number | null = null;
const unsubscribe = api.onWindowFocus(({ kind, ts }) => {
if (kind === 'blur') {
const elapsedMsSinceFocus = lastFocusTs !== null ? ts - lastFocusTs : null;
report('app', 'focus_lost', {
ms_since_last_focus: elapsedMsSinceFocus,
});
lastBlurTs = ts;
} else {
const elapsedMsAway = lastBlurTs !== null ? ts - lastBlurTs : null;
report('app', 'focus_gained', {
ms_away: elapsedMsAway,
});
lastFocusTs = ts;
}
});
return () => {
unsubscribe();
};
}, []);
}
+2
View File
@@ -13,6 +13,7 @@ import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
import onboardingProgressReducer from '@/app/components/Onboarding/OnboardingProgressSlice';
export const store = configureStore({
reducer: {
@@ -30,6 +31,7 @@ export const store = configureStore({
update: updateReducer,
models: modelsReducer,
interaction: interactionReducer,
onboardingProgress: onboardingProgressReducer,
},
});