From 0c841bdad48fa5561208aeb87066d4ab7efd8b4a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 9 May 2026 01:40:37 -0700 Subject: [PATCH] =?UTF-8?q?[eric]=20onboarding=20revamp=20=E2=80=94=20agen?= =?UTF-8?q?tic=20cursor=20walks=20users=20through=208=20setup=20steps,=20W?= =?UTF-8?q?IP=20fixes:=20Gemini=20schema=20scrub,=20OpenAI=20GPT-5=20routi?= =?UTF-8?q?ng,=20websearch=20cascade,=20gpt-5=20is=20still=20a=20bit=20fla?= =?UTF-8?q?ky...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/agents/agent_manager.py | 58 +- backend/apps/agents/anthropic_proxy.py | 97 +- backend/apps/agents/openai_passthrough.py | 154 +++ backend/apps/agents/providers/registry.py | 20 +- backend/apps/auth/router.py | 2 +- backend/apps/dashboards/dashboards.py | 92 ++ backend/apps/nine_router.py | 131 +- backend/apps/service/client.py | 33 +- backend/apps/service/service.py | 43 +- backend/auth.py | 53 +- backend/requirements.txt | 4 + electron/main.js | 37 + electron/preload.js | 10 + frontend/src/app/Main.tsx | 117 +- .../components/ElementSelectionContext.tsx | 19 + .../src/app/components/Layout/AppShell.tsx | 12 +- .../Onboarding/OnboardingDirector.ts | 196 +++ .../components/Onboarding/OnboardingPanel.tsx | 762 +++++++++++ .../Onboarding/OnboardingProgressSlice.ts | 199 +++ .../Onboarding/OnboardingRoadmapModal.tsx | 274 ++++ .../components/Onboarding/OnboardingRoot.tsx | 251 ++++ .../components/Onboarding/ac/ACGestures.ts | 163 +++ .../Onboarding/ac/ACMultiChoice.tsx | 145 +++ .../app/components/Onboarding/ac/ACPopup.tsx | 209 +++ .../components/Onboarding/ac/ACTypewriter.ts | 119 ++ .../Onboarding/ac/AgenticCursor.tsx | 378 ++++++ .../app/components/Onboarding/ac/acRuntime.ts | 625 +++++++++ .../components/Onboarding/ac/cursorStore.ts | 87 ++ .../src/app/components/Onboarding/eventBus.ts | 123 ++ .../Onboarding/hooks/useOnboardingProgress.ts | 28 + .../src/app/components/Onboarding/index.ts | 9 + .../app/components/Onboarding/selectors.ts | 199 +++ .../app/components/Onboarding/steps/index.ts | 29 + .../Onboarding/steps/skipPredicates.ts | 54 + .../Onboarding/steps/step01_connectModel.ts | 84 ++ .../Onboarding/steps/step02_enableActions.ts | 62 + .../Onboarding/steps/step03_launchAgent.ts | 42 + .../Onboarding/steps/step04_useBrowser.ts | 29 + .../steps/step05_agentUseBrowser.ts | 51 + .../steps/step06_agentControlAgents.ts | 71 ++ .../Onboarding/steps/step07_installSkill.ts | 47 + .../Onboarding/steps/step08_makeApp.ts | 57 + .../app/components/Onboarding/steps/types.ts | 70 + .../app/components/Onboarding/telemetry.ts | 32 + .../src/app/components/OnboardingModal.tsx | 1136 ----------------- .../app/components/OnboardingWalkthrough.tsx | 413 ------ frontend/src/app/components/SignInGate.tsx | 497 ++++++-- .../src/app/pages/AgentChat/ChatInput.tsx | 16 + .../src/app/pages/Dashboard/AgentCard.tsx | 14 +- .../src/app/pages/Dashboard/Dashboard.tsx | 26 +- .../app/pages/Dashboard/DashboardToolbar.tsx | 12 +- .../app/pages/Dashboard/useCanvasControls.ts | 142 ++- frontend/src/app/pages/Settings/Settings.tsx | 95 +- .../src/app/pages/Skills/SkillBuilderChat.tsx | 1 + frontend/src/app/pages/Skills/Skills.tsx | 10 +- frontend/src/app/pages/Tools/Tools.tsx | 48 +- frontend/src/app/pages/Views/ViewEditor.tsx | 34 +- frontend/src/app/pages/Views/Views.tsx | 19 +- frontend/src/shared/hooks/useWindowFocus.ts | 54 + frontend/src/shared/state/store.ts | 2 + 60 files changed, 5935 insertions(+), 1861 deletions(-) create mode 100644 backend/apps/agents/openai_passthrough.py create mode 100644 frontend/src/app/components/Onboarding/OnboardingDirector.ts create mode 100644 frontend/src/app/components/Onboarding/OnboardingPanel.tsx create mode 100644 frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts create mode 100644 frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx create mode 100644 frontend/src/app/components/Onboarding/OnboardingRoot.tsx create mode 100644 frontend/src/app/components/Onboarding/ac/ACGestures.ts create mode 100644 frontend/src/app/components/Onboarding/ac/ACMultiChoice.tsx create mode 100644 frontend/src/app/components/Onboarding/ac/ACPopup.tsx create mode 100644 frontend/src/app/components/Onboarding/ac/ACTypewriter.ts create mode 100644 frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx create mode 100644 frontend/src/app/components/Onboarding/ac/acRuntime.ts create mode 100644 frontend/src/app/components/Onboarding/ac/cursorStore.ts create mode 100644 frontend/src/app/components/Onboarding/eventBus.ts create mode 100644 frontend/src/app/components/Onboarding/hooks/useOnboardingProgress.ts create mode 100644 frontend/src/app/components/Onboarding/index.ts create mode 100644 frontend/src/app/components/Onboarding/selectors.ts create mode 100644 frontend/src/app/components/Onboarding/steps/index.ts create mode 100644 frontend/src/app/components/Onboarding/steps/skipPredicates.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step01_connectModel.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step02_enableActions.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step07_installSkill.ts create mode 100644 frontend/src/app/components/Onboarding/steps/step08_makeApp.ts create mode 100644 frontend/src/app/components/Onboarding/steps/types.ts create mode 100644 frontend/src/app/components/Onboarding/telemetry.ts delete mode 100644 frontend/src/app/components/OnboardingModal.tsx delete mode 100644 frontend/src/app/components/OnboardingWalkthrough.tsx create mode 100644 frontend/src/shared/hooks/useWindowFocus.ts diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 457d668b..5847608b 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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 diff --git a/backend/apps/agents/anthropic_proxy.py b/backend/apps/agents/anthropic_proxy.py index 18ce9784..f4cd2a56 100644 --- a/backend/apps/agents/anthropic_proxy.py +++ b/backend/apps/agents/anthropic_proxy.py @@ -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) diff --git a/backend/apps/agents/openai_passthrough.py b/backend/apps/agents/openai_passthrough.py new file mode 100644 index 00000000..a8399de7 --- /dev/null +++ b/backend/apps/agents/openai_passthrough.py @@ -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:/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, + ) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 10f398bd..6355a306 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -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 diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index 8fd439e7..08888ec6 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -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 diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index ba502dd1..6577f1dd 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -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) diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 21f6b6fb..50f4189d 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -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/` 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.""" diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index d3d3da4f..7a5df81e 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -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: diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 1ef21098..fa0df210 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -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") diff --git a/backend/auth.py b/backend/auth.py index 27b7ec54..ff76dfb1 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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 `/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 `/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", diff --git a/backend/requirements.txt b/backend/requirements.txt index 220f8e2f..2f166bf0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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. \ No newline at end of file diff --git a/electron/main.js b/electron/main.js index b89eca3c..a7712f59 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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) { diff --git a/electron/preload.js b/electron/preload.js index f7f68a3f..9427d55a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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 diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index bbae9895..3acd03ad 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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(null); - const [skipTs, setSkipTs] = useState(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} - { - // 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); - }} - /> + ); @@ -529,7 +454,7 @@ const ThemedApp: React.FC = () => { - + diff --git a/frontend/src/app/components/ElementSelectionContext.tsx b/frontend/src/app/components/ElementSelectionContext.tsx index a5a0ba78..7c0b369b 100644 --- a/frontend/src/app/components/ElementSelectionContext.tsx +++ b/frontend/src/app/components/ElementSelectionContext.tsx @@ -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) => { @@ -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) => { diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 95a5658b..3226a3cf 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -619,6 +619,7 @@ 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 ( handleDashboardItemClick(entry.id)} sx={{ display: 'flex', @@ -982,6 +991,7 @@ const AppShell: React.FC = () => { > dispatch(openSettingsModal())} + data-onboarding="sidebar-settings-button" sx={{ borderRadius: 1.5, py: 0.6, diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts new file mode 100644 index 00000000..9dbce01f --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -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; + store: Store; + 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 | null = null; + private store: Store | 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 { + 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 { + 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 { + 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; +} diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx new file mode 100644 index 00000000..0c42e34f --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -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, +}) => ( + + + +); + +const OnboardingPanel: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const progress = useOnboardingProgress(); + const infoBtnRef = useRef(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(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 ( + <> + + + {progress.panelMode === 'pill' && ( + + { + 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)', + }, + }} + > + + Finish setup + + + {done}/{total} + + + + Continue + + + + + )} + + {progress.panelMode === 'expanded' && ( + + + {/* Header */} + + + + {STAGE_LABELS[stageOf]} + + + {stageDone}/{stageSteps.length} + + + { + report('panel_minimized', { from: 'expanded' }); + progress.setPanelMode('pill'); + }} + sx={{ color: c.text.tertiary, p: 0.4 }} + aria-label="Minimize" + > + + + + + {/* Body — celebration overlay or current step. AnimatePresence + crossfades between them so step transitions feel smooth. */} + + + {justDoneStep ? ( + + + + ) : currentStep ? ( + + { + report('roadmap_opened', { from: 'panel' }); + progress.setPanelMode('roadmap'); + }} + onToggleInfo={() => { + report('info_toggled', { + step_id: currentStep.id, + opening: !infoOpen, + }); + setInfoOpen((v) => !v); + }} + running={progress.running} + /> + + ) : ( + + + + )} + + + + + )} + + + + {/* Floating "?" info popover, anchored to the info icon. Renders + OUTSIDE the panel container so it can extend to the left without + clipping. */} + {infoOpen && currentStep && ( + setInfoOpen(false)} + tokens={c} + /> + )} + + + + ); +}; + +interface StepCardProps { + step: ReturnType & {}; + tokens: ReturnType; + cursorIconRef: React.MutableRefObject; + infoBtnRef: React.MutableRefObject; + onShowMe: () => void; + onOpenRoadmap: () => void; + onToggleInfo: () => void; + running: boolean; +} + +const StepCardBody: React.FC = ({ + step, + tokens: c, + cursorIconRef, + infoBtnRef, + onShowMe, + onOpenRoadmap, + onToggleInfo, + running, +}) => { + if (!step) return null; + return ( + + + {step.title} + + + {step.description} + + + + {step.videoSrc && ( + ) => { + (e.currentTarget as HTMLVideoElement).style.display = 'none'; + }} + sx={{ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + )} + {step.videoDurationLabel && ( + + {step.videoDurationLabel} - Demo + + )} + + + + + + See all todos + + + + + + + ); +}; + +interface CelebrationProps { + step: NonNullable>; + accent: string; +} + +const CelebrationView: React.FC = ({ step, accent }) => { + const c = useClaudeTokens(); + return ( + + + + + + + Done + + + + + {step.title} + + + + + Loading next step… + + + ); +}; + +const AllDoneView: React.FC<{ accent: string; tokens: ReturnType }> = ({ + accent, + tokens: c, +}) => ( + + + + + + You're all set up + + + You've finished the OpenSwarm tour. You can re-run it anytime from Settings → General. + + +); + +interface InfoPopoverProps { + stepId: string; + anchorRef: React.MutableRefObject; + onClose: () => void; + tokens: ReturnType; +} + +const InfoPopover: React.FC = ({ 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 ( + + + + + + More info + + + + {text} + + + + ); +}; + +const INFO_BY_STEP_ID: Record = { + 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; diff --git a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts new file mode 100644 index 00000000..4431179c --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts @@ -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; +} + +export interface OnboardingProgressState { + version: typeof SCHEMA_VERSION; + startedAt: number; + completedSteps: string[]; + currentStepId: string | null; + panelMode: PanelMode; + dismissedAt: number | null; + perStepState: Record; + // 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; + 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) ?? {}, + 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) { + // Replace from localStorage on launch. + Object.assign(state, action.payload, { running: false, initialized: true }); + }, + setPanelMode(state, action: PayloadAction) { + state.panelMode = action.payload; + if (action.payload === 'hidden') { + state.dismissedAt = Date.now(); + } else { + state.dismissedAt = null; + } + }, + setCurrentStep(state, action: PayloadAction) { + 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) { + 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) { + state.completedSteps = state.completedSteps.filter((id) => id !== action.payload); + }, + setRunning(state, action: PayloadAction) { + 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; diff --git a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx new file mode 100644 index 00000000..686c7772 --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx @@ -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 ( + + + {/* Header */} + + + + Your roadmap + + + {totalDone}/{total} milestones reached + + + + + + + + {/* Stages */} + + {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 ( + + + + + STAGE {gi + 1} · {stageLabel} + + + + {stageDone}/{group.steps.length} + + + + {STAGE_LABELS[group.stage]} + + + {group.steps.map((step) => { + const isDone = progress.completedSteps.includes(step.id); + const isCurrent = currentStep?.id === step.id && !isDone; + return ( + { + 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 ? ( + + ) : isDone ? ( + + ) : ( + + )} + + {step.title} + + {isCurrent && ( + + current + + )} + + ); + })} + + + ); + })} + + + {/* Footer */} + + + + + + ); +}; + +export default OnboardingRoadmapModal; diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx new file mode 100644 index 00000000..41d8c41b --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -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(null); + const dispatch = useAppDispatch(); + const store = useStore() as Store; + 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 | null = null; + const baselineCaptureAt = Date.now() + 2000; + let lastStatuses: Record = {}; + const seedStatuses = () => { + const sessions = (store.getState() as any).agents?.sessions ?? {}; + const out: Record = {}; + 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 | 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 ( + <> + + + + ); +}; + +export default OnboardingRoot; diff --git a/frontend/src/app/components/Onboarding/ac/ACGestures.ts b/frontend/src/app/components/Onboarding/ac/ACGestures.ts new file mode 100644 index 00000000..b1c2bdec --- /dev/null +++ b/frontend/src/app/components/Onboarding/ac/ACGestures.ts @@ -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 { + 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 { + return new Promise((r) => window.setTimeout(r, ms)); +} diff --git a/frontend/src/app/components/Onboarding/ac/ACMultiChoice.tsx b/frontend/src/app/components/Onboarding/ac/ACMultiChoice.tsx new file mode 100644 index 00000000..40f3f5eb --- /dev/null +++ b/frontend/src/app/components/Onboarding/ac/ACMultiChoice.tsx @@ -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 = ({ + question, + options, + onAnswer, + offset = { x: 14, y: 14 }, +}) => { + const c = useClaudeTokens(); + const { x, y, visible } = useCursorPosition(); + const ref = useRef(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 ( + + + + {question} + + + {options.map((opt) => ( + 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} + + ))} + + + + ); +}; + +export default ACMultiChoice; diff --git a/frontend/src/app/components/Onboarding/ac/ACPopup.tsx b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx new file mode 100644 index 00000000..e51fc1dd --- /dev/null +++ b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx @@ -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 = ({ text, offset = { x: 14, y: 14 } }) => { + const c = useClaudeTokens(); + const { x, y, visible } = useCursorPosition(); + const ref = useRef(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( + 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 ( + + + {/* Tail pointing back at the cursor. */} + + + {displayText} + {isStreaming && ( + + {text.slice(streamCount)} + + )} + + + + ); +}; + +export default ACPopup; diff --git a/frontend/src/app/components/Onboarding/ac/ACTypewriter.ts b/frontend/src/app/components/Onboarding/ac/ACTypewriter.ts new file mode 100644 index 00000000..8c3d7123 --- /dev/null +++ b/frontend/src/app/components/Onboarding/ac/ACTypewriter.ts @@ -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 /