defluff (frontend + backend): strip em-dashes + shorten docstrings + drop dead UI files (cosmetic only, no schedule code)

This commit is contained in:
ciregenz
2026-05-20 05:36:17 -07:00
parent 5b0c6e1df3
commit f59bf0db9b
118 changed files with 853 additions and 4202 deletions
+18 -77
View File
@@ -11,12 +11,7 @@ import logging
logger = logging.getLogger(__name__)
# In-flight dedup map for generate-group-meta. Keyed by (session_id, group_id).
# When the frontend issues N concurrent requests for the same group (which it
# can during heavy streaming), we only fire ONE upstream Anthropic call and
# return the same Future to all callers. Eliminates the 429 thundering herd
# without changing retry/fallback semantics — each unique (session, group)
# still gets its full retry budget, just not multiplied by N callers.
# Dedup concurrent generate-group-meta calls; collapses the 429 thundering herd by sharing one upstream Future per (session, group).
_group_meta_inflight: dict[tuple[str, str], asyncio.Future] = {}
@asynccontextmanager
@@ -32,7 +27,6 @@ async def agents_lifespan():
agents = SubApp("agents", agents_lifespan)
# REST Endpoints
@agents.router.get("/sessions")
async def list_sessions(dashboard_id: str = ""):
@@ -71,12 +65,7 @@ async def send_message(session_id: str, body: dict):
if not prompt:
raise HTTPException(status_code=400, detail="prompt is required")
# Pre-flight MCP suggestion (Phase 3, Layer N). Runs in parallel with
# the agent launch path — if it produces suggestions, they're
# surfaced inline in the chat via agent:mcp_suggestions WS event.
# Fails open: any error from the classifier is swallowed and the
# agent proceeds normally. The classifier is short-circuited for
# obviously-local prompts (greetings, shell commands, file paths).
# Run MCP-suggestion classifier in parallel with the agent launch; fails open.
try:
from backend.apps.agents.mcp_preflight import run_preflight
from backend.apps.agents.ws_manager import ws_manager as _ws
@@ -93,7 +82,6 @@ async def send_message(session_id: str, body: dict):
except Exception:
pass
# Non-blocking — don't gate the agent on the classifier.
import asyncio as _asyncio
_asyncio.create_task(_emit_preflight())
except Exception:
@@ -161,11 +149,7 @@ async def generate_group_meta(session_id: str, body: dict):
if not group_id or not tool_calls:
raise HTTPException(status_code=400, detail="group_id and tool_calls are required")
# In-flight dedup. If an identical request is already running, await its
# result instead of firing another Anthropic call. This is the entire fix
# for the 429 storm we were seeing — N concurrent identical requests
# collapse to 1 upstream call. Refinement requests bypass dedup since
# they may legitimately want fresh results with different inputs.
# Dedup: share an in-flight Future across callers; refinement requests bypass since they may want fresh results.
is_refinement = body.get("is_refinement", False)
key = (session_id, group_id)
if not is_refinement:
@@ -174,8 +158,7 @@ async def generate_group_meta(session_id: str, body: dict):
try:
return await existing
except Exception:
# If the in-flight call failed, fall through and try again
# ourselves rather than propagating someone else's error.
# In-flight call failed; retry ourselves rather than propagate someone else's error.
pass
future: asyncio.Future = asyncio.get_event_loop().create_future()
@@ -197,7 +180,6 @@ async def generate_group_meta(session_id: str, body: dict):
future.set_exception(e)
raise
finally:
# Always clear our slot if we own it, so the next request runs fresh.
if not is_refinement and _group_meta_inflight.get(key) is future:
_group_meta_inflight.pop(key, None)
@@ -267,12 +249,7 @@ async def resume_session(session_id: str):
@agents.router.post("/sessions/{session_id}/warm-cache")
async def warm_session_cache(session_id: str):
"""Fire a max_tokens=1 dummy request through the agent path so
Anthropic processes the system+tools prefix and writes the prompt
cache. The next real user turn lands a cache hit instead of paying
cold-start TTFT. Non-blocking, fire-and-forget on the frontend.
Returns 200 even on failure (best-effort).
"""
"""Fire a max_tokens=1 dummy request to prime the Anthropic prompt cache; best-effort."""
try:
await agent_manager.warm_prompt_cache(session_id)
except Exception:
@@ -280,10 +257,6 @@ async def warm_session_cache(session_id: str):
return {"ok": True}
# ---------------------------------------------------------------------------
# 9Router / Subscription endpoints
# ---------------------------------------------------------------------------
@agents.router.get("/subscriptions/status")
async def subscriptions_status():
"""Check if 9Router is running and list connected providers."""
@@ -292,8 +265,7 @@ async def subscriptions_status():
return {"running": False, "providers": [], "models": []}
connections = await get_providers()
models = await get_models()
# Frontend consumers (OnboardingModal, Settings) read
# `data.providers.connections` — preserve that envelope here.
# Frontend reads data.providers.connections; preserve the envelope.
return {"running": True, "providers": {"connections": connections}, "models": models}
@@ -310,11 +282,7 @@ async def subscriptions_connect(body: dict):
if not is_running():
raise HTTPException(status_code=503, detail="9Router not available. Please install Node.js.")
# If reconnecting a primary lane (e.g. gemini-cli), drop its cascade
# siblings first. The registry prefers antigravity over gemini-cli
# when both are present, so a stale antigravity token would keep
# 400ing even after gemini-cli refreshes. Wiping the sibling forces
# the registry onto the freshly reconnected lane.
# Reconnecting gemini-cli must wipe antigravity; registry prefers AG and a stale AG token would 400 after gemini-cli refreshes.
cascade = _PROVIDER_CASCADE_REMOVES.get(provider, [])
if cascade:
try:
@@ -325,7 +293,6 @@ async def subscriptions_connect(body: dict):
try:
result = await start_oauth(provider)
# For auth_code flows, store pending state so the callback can exchange
if result.get("flow") == "authorization_code" and result.get("state"):
from backend.main import _pending_oauth
_pending_oauth[result["state"]] = {
@@ -399,8 +366,7 @@ async def subscriptions_models():
@agents.router.post("/probe-model")
async def probe_model(body: dict):
"""1-token health probe. Returns {ok, latency_ms} or {ok:false, error}
or {ok:true, skipped:true} when the route's ambiguous (silent beats wrong)."""
"""1-token health probe; returns latency or skipped when the route is ambiguous (silent beats wrong)."""
import time as _time
short_name = (body or {}).get("model") or ""
if not short_name:
@@ -459,8 +425,7 @@ async def probe_model(body: dict):
except Exception as e:
msg = str(e).splitlines()[0] if str(e) else type(e).__name__
low = msg.lower()
# Suppress transients chat will retry naturally and probe-time aliasing
# 404s often differ from how the chat path resolves the same id.
# Suppress transients: chat retries naturally and probe-time alias 404s often differ from chat resolution.
if any(s in low for s in (
"timeout", "timed out",
"connection reset", "connection aborted",
@@ -489,7 +454,7 @@ async def list_models():
try:
conns = await _9r_providers()
raw_providers = {c.get("provider", "") for c in conns if c.get("isActive") or c.get("testStatus") == "active"}
# 9Router uses "claude"; our models use api="anthropic" — map across.
# 9Router uses "claude"; our models use api="anthropic". Map across.
_9R_TO_API = {
"claude": "anthropic",
"codex": "codex",
@@ -501,8 +466,7 @@ async def list_models():
logger.debug(f"Failed to fetch 9Router providers: {e}")
def _serialize(models: list[dict]) -> list[dict]:
# Native models. Tiers describe the model itself; billing_kind
# describes the user's wallet for it. Pricing is shown only for paid.
# Tiers describe the model; billing_kind describes the wallet. Pricing shown only for paid.
from backend.apps.agents.providers.registry import (
COST_PER_1M_TOKENS,
compute_tiers,
@@ -533,7 +497,7 @@ async def list_models():
"reasoning": bool(m.get("reasoning", False)),
"input_cost_per_1m": input_cost,
"output_cost_per_1m": output_cost,
# Strict subscription doesn't count. Pickerside uses Subscription chip.
# Strict free; subscriptions show via the picker's Subscription chip.
"is_free": billing_kind == "free",
"billing_kind": billing_kind,
"tiers": list(tiers),
@@ -554,8 +518,7 @@ async def list_models():
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
api_variants = [m for m in anthropic_models if m.get("route") == "api"]
# Pro mode shows two groups (Pro proxy + Anthropic alternates via cc/api);
# own-key mode collapses to one Anthropic group using adaptive routing.
# Pro mode splits into Pro proxy + Anthropic alternates; own-key collapses to one adaptive group.
notes: list[dict] = []
if is_openswarm_pro:
result["OpenSwarm Pro"] = _serialize(adaptive)
@@ -620,8 +583,7 @@ async def list_models():
if visible:
result[provider_name] = visible
# OR catalog fetched straight from openrouter.ai (independent of 9Router
# boot state) so picker populates the moment a key lands.
# Fetch OpenRouter catalog directly (independent of 9Router) so picker fills the moment a key lands.
if has_openrouter_key:
try:
from backend.apps.agents.providers.registry import fetch_openrouter_models
@@ -669,10 +631,7 @@ async def list_models():
entries = sorted(by_vendor[vendor], key=lambda x: x["label"].lower())
result[f"OpenRouter · {pretty}"] = entries
# User-configured custom OpenAI-compatible providers (Ollama Cloud, Together, etc).
# Each provider becomes its own group in the picker; each model is addressed via
# the `custom/<slug>/<model_id>` value, which `_find_builtin_model` synthesises
# into a route='api' / api='custom' entry at request time.
# Custom OpenAI-compatible providers (Ollama Cloud, Together, etc); addressed via custom/<slug>/<model_id>.
from backend.apps.agents.providers.registry import _custom_provider_slug_for_lookup
for cp in (getattr(settings, "custom_providers", None) or []):
cp_name = (getattr(cp, "name", "") or "").strip()
@@ -707,27 +666,14 @@ async def list_models():
return {"models": result, "notes": notes}
# Google's two OAuth lanes (gemini-cli and antigravity) share user-facing
# meaning (both = "Google subscription") but 9Router treats them as
# separate connections with independent token lifecycles. The registry
# prefers `ag/` over `gc/` whenever AG is active because AG bypasses the
# thoughtSignature validator that breaks multi-step tool turns. That
# preference becomes a footgun when AG's token expires silently: the
# user reconnects "Google", only gemini-cli refreshes, and every request
# still routes through the stale AG token -> 400 Invalid argument.
#
# Cascade is one-directional. gemini-cli is the primary lane the UI
# exposes; operations on it sweep antigravity too. Direct operations on
# antigravity (e.g. an explicit AG opt-in/out path) MUST NOT cascade
# back to gemini-cli or we'd nuke the user's main Google connection.
# gemini-cli and antigravity are two Google OAuth lanes; registry prefers AG, so we cascade-wipe AG when reconnecting gemini-cli to avoid stale-AG 400s. One-directional: AG operations MUST NOT cascade back.
_PROVIDER_CASCADE_REMOVES: dict[str, list[str]] = {
"gemini-cli": ["antigravity"],
}
async def _delete_provider_connections(providers: list[str]) -> int:
"""Delete all 9Router connections whose provider is in the given list.
Returns the count actually removed. Silent if 9Router is unreachable."""
"""Delete 9Router connections in `providers`; returns count removed, silent on 9Router unreachable."""
import httpx
from backend.apps.nine_router import NINE_ROUTER_API, get_providers
try:
@@ -748,12 +694,7 @@ async def _delete_provider_connections(providers: list[str]) -> int:
@agents.router.post("/subscriptions/disconnect")
async def subscriptions_disconnect(body: dict):
"""Disconnect a subscription provider via 9Router.
For Google's paired lanes (gemini-cli + antigravity), wipe BOTH so a
subsequent reconnect lands on a clean slate instead of resurrecting
a stale sibling.
"""
"""Disconnect a subscription provider via 9Router; cascades-wipe Google's paired lanes."""
provider = body.get("provider", "")
if not provider:
raise HTTPException(status_code=400, detail="provider required")
+9 -25
View File
@@ -1,23 +1,5 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing the MCP activation gate.
Tools:
- MCPList: enumerate installed MCP servers (active + available).
- MCPSearch(query): rank servers by relevance to a free-form query.
- MCPActivate(server_name): activate a server for the rest of the session.
The activation gate is the dispatch-layer enforcement of the product invariant
"all MCP actions only via ToolSearch": the model can only reach an MCP server's
tools if the user has approved MCPActivate for that server, which appends to
session.active_mcps. _build_mcp_servers in agent_manager.py intersects connected
MCPs with that list before handing them to the SDK, so unactivated servers are
literally unreachable — the gate cannot be bypassed by ignoring prompt rules.
HITL: the model's invocation of MCPActivate goes through agent_manager's pre-
tool approval hook just like any other tool call — the user is prompted to
approve activation in the standard ApprovalBar UI. No separate HITL inside this
server.
"""
"""Stdio MCP server exposing the MCP activation gate (MCPList/MCPSearch/MCPActivate)."""
import json
import os
@@ -69,7 +51,7 @@ TOOLS = [
"description": (
"Request activation of an MCP server for this session. Triggers a "
"user approval prompt; on approve the server's tools become callable "
"next turn. Always confirm the server name via MCPList/MCPSearch first "
"next turn. Always confirm the server name via MCPList/MCPSearch first; "
"invalid names return alternatives instead of activating."
),
"inputSchema": {
@@ -81,7 +63,7 @@ TOOLS = [
},
"reason": {
"type": "string",
"description": "Why you need it shown to the user in the approval prompt.",
"description": "Why you need it; shown to the user in the approval prompt.",
},
},
"required": ["server_name"],
@@ -133,7 +115,7 @@ def format_servers(servers: list[dict], heading: str = "") -> str:
name = s.get("name", "")
desc = s.get("description") or f"{name} integration"
status = s.get("status", "available")
lines.append(f"- `{name}` [{status}] {desc}")
lines.append(f"- `{name}` [{status}]; {desc}")
return "\n".join(lines)
@@ -189,15 +171,17 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"isError": True,
}
if result.get("status") == "already_active":
return {"content": [{"type": "text", "text": f"`{server_name}` is already active for this session its tools should be callable now."}]}
return {"content": [{"type": "text", "text": f"`{server_name}` is already active for this session; its tools should be callable now."}]}
if result.get("status") == "activated":
return {
"content": [{
"type": "text",
"text": (
f"Activated `{server_name}`. Its tools (`mcp__{server_name}__*`) "
f"will be callable on the NEXT turn. End this turn now and the user's "
f"next message will see the new tools."
f"are NOT callable in this turn; the transport snapshot is "
f"already locked. This turn will end automatically and a "
f"hidden continuation turn will fire with the new tools "
f"loaded. Do not attempt any other tool call now."
),
}],
}
+19 -86
View File
@@ -11,7 +11,7 @@ class AgentConfig(BaseModel):
system_prompt: Optional[str] = None
allowed_tools: list[str] = Field(default_factory=lambda: ["Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"])
max_turns: Optional[int] = None
target_directory: Optional[str] = None # if None, uses repo root
target_directory: Optional[str] = None
dashboard_id: Optional[str] = None
class ApprovalRequest(BaseModel):
@@ -55,26 +55,15 @@ class Message(BaseModel):
forced_tools: Optional[list[str]] = None
images: Optional[list[dict]] = None
hidden: bool = False
# Optional client-generated id used by the frontend to reconcile an
# optimistic message bubble (rendered synchronously on send) with the
# server-confirmed echo. Plumbed through send_message and round-tripped
# back via the agent:message WS event so the frontend can dedupe.
# Frontend-generated id for optimistic-bubble dedup against the server echo.
client_message_id: Optional[str] = None
# Wall-clock duration in milliseconds spent producing this message's
# content. For thinking blocks: time from content_block_start →
# content_block_stop. Lets the persisted ThinkingBubble show
# "Thought for Ns" on reload instead of falling back to the static
# "Thoughts" label. Optional for back-compat with messages saved
# before this field existed.
# Wall-clock ms producing this message's content; for thinking, content_block_start -> stop. Lets reloaded bubbles show "Thought for Ns".
elapsed_ms: Optional[int] = None
# Approximate output tokens for this message's content. For thinking
# blocks we use the same char/3.6 heuristic the live UI uses so the
# number frozen on the persisted bubble matches what the user saw
# rising during the stream. Pure display, not billing.
# Approx output tokens; thinking uses char/3.6 to match the live UI's count. Display only.
tokens: Optional[int] = None
# tool_count drives the "3 tools used" segment on the thinking pill.
# Drives the "N tools used" segment on the thinking pill.
tool_count: Optional[int] = None
# combined input + output + children tokens for the turn (overloaded name).
# Combined input + output + children tokens for the turn (overloaded name).
input_tokens: Optional[int] = None
class MessageBranch(BaseModel):
@@ -101,40 +90,22 @@ class AgentSession(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
max_turns: Optional[int] = None
cwd: Optional[str] = None
# Origin remote and branch resolved at session start. Persisted so a
# resumed session reattaches to the same project even if the user has
# since `cd`'d elsewhere; also surfaced in the session list UI so the
# user can tell two sessions apart by repo.
# Resolved at session start so resume reattaches to the same repo even after the user cd's elsewhere.
repo_url: Optional[str] = None
branch: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
# Wall-clock of the first stream event from the agent SDK. Set once
# at the start of the first turn so resumed sessions can show "first
# response was at HH:MM" in the session list without rescanning the
# message log.
# Wall-clock of the first stream event so resumed sessions can show "first response at HH:MM" without rescan.
first_response_at: Optional[datetime] = None
# Operational log of HITL approval decisions, one entry per request:
# {tool, behavior, decision_ms}. Persisted alongside the session so a
# reload restores the full approval timeline (which calls were
# approved, denied, and how long each took).
# HITL approval log: {tool, behavior, decision_ms} per entry.
approval_decisions: list[dict] = Field(default_factory=list)
cost_usd: float = 0.0
tokens: dict[str, int] = Field(default_factory=lambda: {"input": 0, "output": 0})
# Total wall-clock ms the agent spent in `status="running"`. Accumulates
# across turns; persists across resume. Used by the session-close
# report so we can report "agent active time" alongside total session
# duration. Off by default so legacy sessions deserialize cleanly.
# Total ms in status="running", accumulated across turns/resume; powers session-close "agent active time".
agent_active_ms: int = 0
# Accumulated wall-clock ms spent on each model. Updated when the
# active model changes (model switch) or on close. Surfaced in the
# session header so the user can see "Sonnet: 45s · Haiku: 12s"
# without scanning turns by hand.
# Per-model wall-clock ms; updated on model switch or close.
time_per_model: dict[str, int] = Field(default_factory=dict)
# Per-tool latency rollup: { tool_name: { count, total_ms, max_ms } }.
# Populated as tools complete. Surfaced in the session "tools used"
# row so the user can see which tool calls were slow without
# opening every turn.
# Per-tool latency: { tool_name: { count, total_ms, max_ms } }.
tool_latencies: dict[str, dict] = Field(default_factory=dict)
browser_domains: list[str] = Field(default_factory=list)
messages: list[Message] = Field(default_factory=list)
@@ -146,58 +117,20 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# Stronger than needs_fork: when True, the next turn drops `resume=`
# entirely and replays history into a brand-new sdk_session_id. This
# is the only way to make the bundled CLI re-read mcp_servers from
# the rebuilt options dict — `fork_session=True` only forks the
# conversation tree, it inherits the original transport's MCP server
# set. Set after MCPActivate when prior turns exist so the newly
# activated server's tools actually reach the model.
# Stronger than needs_fork: drop resume= and replay history into a fresh sdk_session_id; fork_session alone won't re-read mcp_servers.
needs_fresh_session: bool = False
# Set when MCPActivate (or analogous activation) wants the agent to
# auto-continue immediately after the current turn ends — without
# requiring the user to type another message. The agent loop reads
# this at the end of `_run_agent_loop`; if set, it clears it and
# dispatches a new hidden turn with `pending_continuation_prompt` as
# the prompt. Race-free vs. the original asyncio-task approach.
# Auto-continue: agent loop dispatches a hidden turn at end-of-loop using pending_continuation_prompt. Race-free vs background tasks.
pending_continuation: bool = False
pending_continuation_prompt: Optional[str] = None
# Sanitized server names (matching tools_lib._sanitize_server_name) of MCP
# servers the model has explicitly activated this session via the
# MCPActivate meta-tool. Empty by default — the gate in
# _build_mcp_servers intersects connected MCPs with this list, so no
# MCP tool is callable until the model searches for and activates a
# server. The product invariant is that this is non-bypassable: the
# filter lives at the dispatch layer (mcp_servers passed to the SDK),
# not the prompt layer.
# Sanitized server names model has explicitly activated this session; _build_mcp_servers intersects connected MCPs with this. Non-bypassable; dispatch-layer gate.
active_mcps: list[str] = Field(default_factory=list)
# Estimated framework preamble tokens (preset + tool defs + MCP descs +
# composed prompt). Subtracted from displayed input for honest "this turn"
# numbers. Heuristic; clamped >= 0.
# Heuristic preamble tokens (preset + tool defs + MCP descs + composed prompt); subtracted from displayed input.
framework_overhead_tokens: int = 0
# Compaction state. compact_threshold_pct is the live ctx_used ratio
# that triggers _maybe_compact at the next turn boundary — turn-based
# thresholds break under uneven workloads (one big Bash dump fills
# context fast; 30 chitchat turns barely move it). 0.65 = 130K of the
# 200K standard tier. compacted_through_msg_id is the last message id
# covered by the most recent summary so we don't re-summarize on
# every turn.
# Live ctx_used ratio triggering _maybe_compact at the next turn boundary; turn-based thresholds break under uneven workloads. 0.65 = 130K of 200K.
compact_threshold_pct: float = 0.65
compacted_through_msg_id: Optional[str] = None
# Pre-send hard guard. Fires later than the compaction threshold —
# 0.90 of 200K = 180K — to give the auto-compact path a chance to
# bring the request back under the ceiling. If still over after
# compaction, LRU-trim the oldest active_mcps. Past this we surface
# the friendly context-overflow card instead of letting a 429 hit.
# Hard pre-send guard at 0.90 (= 180K); past compaction we LRU-trim active_mcps, then surface the overflow card.
context_soft_cap_pct: float = 0.90
context_window: int = 200_000
# How much the model should "think" before answering. Provider-agnostic
# value that gets translated per-API in agent_manager:
# off — no thinking
# low — minimal thinking (fastest)
# medium — balanced
# high — extensive thinking (slowest, smartest)
# auto — let the model / provider default decide (recommended)
# Only applies to models flagged with reasoning: True in the registry.
# Existing sessions without this field will default to "auto".
# Provider-agnostic thinking level (off/low/medium/high/auto), translated per-API in agent_manager; only affects reasoning-flagged models.
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
+7 -64
View File
@@ -9,19 +9,7 @@ logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manages WebSocket connections and bridges HITL approval requests.
Every outbound event flows through the seq log so reconnecting
clients can replay missed events. The send happens *under* the
per-session lock yielded by `seq_log.stamp(...)`, which guarantees
wire order matches seq order even under concurrent broadcasts.
A WS disconnect (`disconnect_session`) ONLY removes the socket
from the connection registry. It does NOT cancel the underlying
agent task. The task lives on `agent_manager.tasks`; only an
explicit `agent:stop`, REST `/close`, natural completion, or
process shutdown ends a run.
"""
"""Manages WebSocket connections and HITL approval bridging; events flow through seq_log so reconnects can replay."""
def __init__(self):
self.connections: dict[str, list[WebSocket]] = {}
@@ -53,19 +41,7 @@ class ConnectionManager:
]
async def send_to_session(self, session_id: str, event: str, data: dict):
"""Broadcast a session event with monotonic sequencing.
The send to every socket happens inside the seq_log lock so a
slow/dead WS doesn't reorder events on the fast ones. If a
single send raises (broken pipe, half-open socket), we log and
continue — the ring buffer still has the event so the client
will replay it on reconnect.
For terminal status events (completed/stopped/error) we also
atomically persist the payload to disk; a client that returns
after a process restart can then resolve the spinner via
`seq_log.load_terminal(...)` instead of being stuck.
"""
"""Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk."""
async with seq_log.stamp(session_id, event, data) as (seq, payload_str):
for ws in list(self.connections.get(session_id, [])):
try:
@@ -77,38 +53,17 @@ class ConnectionManager:
await ws.send_text(payload_str)
except Exception:
logger.debug("send_to_session: global send failed", exc_info=True)
# Persist terminal events under the lock so a concurrent
# `agent:status: running` can't race past and overwrite
# the disk file with a stale state.
# Persist under the lock so a concurrent running status can't race past and overwrite with stale state.
if event == "agent:status" and data.get("status") in TERMINAL_STATUSES:
seq_log.persist_terminal(session_id, payload_str)
async def replay_to(
self, session_id: str, websocket: WebSocket, last_seq: int
) -> dict:
"""Replay buffered events with seq > last_seq to one socket.
Returns a small ack envelope describing what happened so the
caller (the WS handler) can send a `server:resume_ack` frame.
Three cases:
1. `events` non-empty: replay them in order; ack carries
`from_seq`, `to_seq`.
2. No buffer at all (process restarted, session evicted)
but a persisted terminal exists: send it; ack signals
`terminal_only=True`.
3. `last_seq` predates the oldest buffered seq: emit
`agent:gap_detected`; client REST-refreshes the session.
"""
"""Replay buffered events with seq > last_seq; returns ack envelope for the resume handshake."""
oldest, newest, events = seq_log.replay(session_id, last_seq)
# Check for gap FIRST. If the client's last_seq is below the
# buffer's oldest seq, we can't deliver everything they
# missed — silently replaying only the in-buffer tail would
# leave a hole in their state. Tell them to REST-refresh
# instead, even if the tail looks safe to send.
# Treat last_seq=0 as "fresh client" — they want a full
# replay of whatever's in the buffer, not a gap signal.
# Gap-check first: if last_seq predates the buffer, signal REST-refresh; last_seq=0 means fresh client (full replay).
if last_seq > 0 and oldest is not None and last_seq < oldest - 1:
gap_payload = json.dumps({
"event": "agent:gap_detected",
@@ -162,7 +117,6 @@ class ConnectionManager:
"to_seq": newest,
}
# Nothing in memory. Try a persisted terminal event.
terminal = seq_log.load_terminal(session_id)
if terminal is not None:
try:
@@ -171,7 +125,6 @@ class ConnectionManager:
pass
return {"ok": True, "replayed": 1, "terminal_only": True}
# Nothing missed, nothing to replay. Caller's caught up.
return {
"ok": True,
"replayed": 0,
@@ -225,12 +178,7 @@ class ConnectionManager:
return out
async def broadcast_global(self, event: str, data: dict):
"""Send a message to all global (dashboard) connections.
Dashboard-scoped events don't go through the per-session seq
log — they're not session-bound and the dashboard WS has its
own resume story (full state refetch on reconnect).
"""
"""Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch)."""
payload = json.dumps({"event": event, "data": data})
for ws in list(self.global_connections):
try:
@@ -245,12 +193,7 @@ class ConnectionManager:
sensitive_label: str | None = None,
sensitive_why: str | None = None,
) -> dict:
"""Send an approval request and wait for the user's response.
Returns the approval decision dict. Times out after `timeout`
seconds (default 10 minutes) so a forgotten request doesn't
permanently park the agent.
"""
"""Send an approval request and wait for the user's decision; 10-minute timeout prevents permanent park."""
future = asyncio.get_event_loop().create_future()
self.pending_futures[request_id] = future
-6
View File
@@ -13,16 +13,10 @@ async def health_lifespan():
health = SubApp("health", health_lifespan)
######################################
# Health Check Endpoints #
######################################
@health.router.get("/check")
@typechecked
async def check() -> PlainTextResponse:
debug("Health check successful")
# Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility
# ALB health checks can be sensitive to JSON responses and Content-Length headers
return PlainTextResponse(
content="OK",
status_code=status.HTTP_200_OK,
+18 -18
View File
@@ -55,9 +55,9 @@ BUILTIN_MODES: list[Mode] = [
Mode(
id="ask",
name="Ask",
description="Read-only conversation. Browse the codebase, search the web, and discuss ideas but no edits, shells, or file writes.",
description="Read-only conversation. Browse the codebase, search the web, and discuss ideas; but no edits, shells, or file writes.",
system_prompt=(
"You are in Ask mode a read-only assistant. Keep responses "
"You are in Ask mode; a read-only assistant. Keep responses "
"natural and conversational. You CAN read files, search the "
"codebase, and search/fetch the web. You CANNOT edit files, run "
"shell commands, or otherwise modify anything; if the user asks "
@@ -88,21 +88,21 @@ BUILTIN_MODES: list[Mode] = [
name="App Builder",
description="Create and iterate on reusable App artifacts.",
system_prompt=(
"You are an App Builder an AI assistant that creates self-contained "
"You are an App Builder; an AI assistant that creates self-contained "
"web apps rendered in an iframe preview.\n\n"
"Your working directory is a dedicated workspace folder pre-seeded with "
"template files. Read the existing files before making changes.\n\n"
"## Critical rules\n\n"
"- The entry point MUST be named `index.html`. Never rename it or create "
"a different HTML file as the main entry point.\n"
"- Write files immediately when you have code ready the user sees a "
"- Write files immediately when you have code ready; the user sees a "
"live preview that auto-refreshes from these files.\n"
"- Always write the complete file content on first creation (do not use "
"Edit for partial patches on new files).\n"
"- For complex apps, split code into separate files (JS, CSS, etc.) "
"and reference them from index.html with relative paths.\n"
"- Always update meta.json with a short name and one-sentence description.\n"
"- Build beautiful, polished UIs with modern design dark themes, smooth "
"- Build beautiful, polished UIs with modern design; dark themes, smooth "
"transitions, proper spacing, and responsive layouts.\n\n"
"Read the SKILL.md reference in your workspace for the full technical "
"specification of the App platform (available globals, file conventions, "
@@ -120,17 +120,17 @@ BUILTIN_MODES: list[Mode] = [
name="Skill Builder",
description="Create and iterate on skills using AI-assisted vibe coding.",
system_prompt=(
"You are a Skill Builder an AI assistant that helps users create, "
"You are a Skill Builder; an AI assistant that helps users create, "
"refine, and iterate on Claude skills (SKILL.md files).\n\n"
"## How Skills Work\n\n"
"A skill is a Markdown file that teaches Claude how to perform a specific task. "
"Skills have YAML frontmatter with `name` and `description` fields, followed by "
"the skill body in Markdown. The description is the primary triggering mechanism "
"the skill body in Markdown. The description is the primary triggering mechanism; "
"it tells Claude when to use the skill.\n\n"
"## Your Working Directory\n\n"
"Your working directory is a dedicated workspace folder for this skill. "
"Write your output directly to these files using the Write tool:\n\n"
"1. **SKILL.md** The complete skill file with YAML frontmatter and Markdown body. "
"1. **SKILL.md**; The complete skill file with YAML frontmatter and Markdown body. "
"Example frontmatter:\n"
" ```\n"
" ---\n"
@@ -138,34 +138,34 @@ BUILTIN_MODES: list[Mode] = [
" description: When to trigger and what this skill does.\n"
" ---\n"
" ```\n\n"
"2. **meta.json** Metadata for the skill builder UI. Always write this file. Example:\n"
"2. **meta.json**; Metadata for the skill builder UI. Always write this file. Example:\n"
' {"name":"My Skill","description":"A short description","command":"my-skill"}\n\n'
"Write these files immediately when you have content ready. The user can see "
"a live preview that auto-refreshes from these files. Always write the "
"complete file content (do not use Edit for partial patches on first creation).\n\n"
"## Skill Creation Process\n\n"
"1. **Understand intent** Ask what the skill should do, when it should trigger, "
"1. **Understand intent**; Ask what the skill should do, when it should trigger, "
"and what the expected output format is.\n"
"2. **Draft the skill** Write a SKILL.md with clear instructions, examples, "
"2. **Draft the skill**; Write a SKILL.md with clear instructions, examples, "
"and good progressive disclosure.\n"
"3. **Iterate** Refine based on user feedback. Update the files each time.\n\n"
"3. **Iterate**; Refine based on user feedback. Update the files each time.\n\n"
"## Skill Writing Best Practices\n\n"
"- Keep SKILL.md under 500 lines; use bundled reference files for large content.\n"
"- The `description` frontmatter is the primary trigger. Make it slightly \"pushy\" "
"- The `description` frontmatter is the primary trigger. Make it slightly \"pushy\"; "
"include both what the skill does AND specific contexts for when to use it.\n"
"- Use imperative form in instructions.\n"
"- Include examples with input/output pairs when helpful.\n"
"- Define output formats explicitly with templates.\n"
"- Use theory of mind explain *why* things matter rather than just MUST directives.\n"
"- Use theory of mind; explain *why* things matter rather than just MUST directives.\n"
"- Think about edge cases, error handling, and progressive disclosure.\n\n"
"## Skill Anatomy\n\n"
"```\n"
"skill-name/\n"
"├── SKILL.md (required) YAML frontmatter + Markdown instructions\n"
"├── SKILL.md (required); YAML frontmatter + Markdown instructions\n"
"└── Bundled Resources (optional)\n"
" ├── scripts/ Executable code for repetitive tasks\n"
" ├── references/ Docs loaded into context as needed\n"
" └── assets/ Files used in output\n"
" ├── scripts/ ; Executable code for repetitive tasks\n"
" ├── references/; Docs loaded into context as needed\n"
" └── assets/ ; Files used in output\n"
"```\n\n"
"Be collaborative and flexible. If the user wants to \"just vibe\", skip the formal "
"process and iterate freely. Always write updated files so the preview stays current."
+1 -4
View File
@@ -14,10 +14,7 @@ from backend.config.paths import MODES_DIR as DATA_DIR
@asynccontextmanager
async def modes_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
# One-time migration: Chat was merged into Ask. Remove a stale built-in
# chat.json if it still has its is_builtin=True signature so users don't
# see two near-identical modes in the picker. Leave alone if a user has
# diverged it (we don't want to wipe customizations).
# Migration: Chat merged into Ask; drop a stale built-in chat.json but leave customized copies alone.
chat_path = os.path.join(DATA_DIR, "chat.json")
if os.path.exists(chat_path):
try:
+1 -1
View File
@@ -127,7 +127,7 @@ class OutputExecute(BaseModel):
# running if the backend code touches anything outside the safe
# data-shaping allowlist. The UI shows those warnings to the user and
# re-submits with force=True after they click "Run Anyway." This is
# a UX gate, not a security one anyone holding the auth token can
# a UX gate, not a security one; anyone holding the auth token can
# set force=True; the value is providing the user explicit visibility
# of what's about to execute.
force: bool = False
+15 -15
View File
@@ -139,7 +139,7 @@ def _inject_token_into_relative_urls(html: str, token: str) -> str:
relative `<link href="styles.css">` / `<script src="x.js">`, so without
this rewrite the sub-resource fetch lands at the auth middleware with no
credentials and gets a 401. Idempotent: skips URLs that already carry a
`token=` param. Skips absolute URLs (CDN, data:, etc.) see prefix list.
`token=` param. Skips absolute URLs (CDN, data:, etc.); see prefix list.
"""
if not token:
return html
@@ -235,7 +235,7 @@ def load_output(output_id: str) -> Output | None:
# descend into. Without this skip-list the workspace endpoint reads
# `node_modules/` (300 MB of MUI source, when it's a real dir and not a
# symlink), `.venv/` (10k+ Python files from the hardlinked cache),
# `__pycache__/`, `dist/`, `.git/`, etc every 2 seconds while the
# `__pycache__/`, `dist/`, `.git/`, etc; every 2 seconds while the
# agent is active. Result: backend CPU pegged on JSON-serializing
# auto-generated chunks the frontend will then throw away. The frontend
# already filters these for display; this skip is the real fix.
@@ -266,14 +266,14 @@ _WALK_MAX_FILE_BYTES = 256 * 1024
def _walk_directory(folder: str) -> dict[str, str]:
"""Walk a directory tree and return {relative_path: content} for all
text files the user is actually authoring. Skips build/install
directories AND truncates oversize files both critical for the
directories AND truncates oversize files; both critical for the
polling endpoint, which is called every 2 s while the agent is
writing code and would otherwise serialize hundreds of MB per poll."""
files: dict[str, str] = {}
if not os.path.isdir(folder):
return files
for root, dirs, filenames in os.walk(folder):
# Mutate `dirs` in place that's how os.walk skips a subtree.
# Mutate `dirs` in place; that's how os.walk skips a subtree.
# Doing it here means we never even stat the children, so a
# 10k-file `.venv/` costs ~one stat (on the dir itself) instead
# of 10k.
@@ -288,7 +288,7 @@ def _walk_directory(folder: str) -> dict[str, str]:
# mis-parsed.
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
try:
# Stat first cheap, lets us skip giant files without
# Stat first; cheap, lets us skip giant files without
# opening + reading them.
size = os.path.getsize(full_path)
if size > _WALK_MAX_FILE_BYTES:
@@ -328,7 +328,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
# parent's ?token= query string, so rewrite the HTML to put the token
# back on every relative URL otherwise sub-resources 401.
# back on every relative URL; otherwise sub-resources 401.
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
@@ -512,7 +512,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
with an optional FastAPI backend) into the workspace, allocates a
free FRONTEND_PORT and writes it into both `.env` and
`.env.example`. BACKEND_PORT stays NONE the agent opts in with
`.env.example`. BACKEND_PORT stays NONE; the agent opts in with
`bash backend_init.sh`. Runtime spawn flips to `bash run.sh` and
the preview pane points at `http://localhost:{FRONTEND_PORT}/`.
`body.files` is ignored in this mode; the snapshot is the source
@@ -524,7 +524,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# An explicit non-empty `files` payload means the caller has flat-mode
# content to write (a saved legacy Output being reseeded). Don't
# clobber that with the React template even if template_mode is the
# new default the migration helper has its own path for that.
# new default; the migration helper has its own path for that.
effective_mode = body.template_mode
if body.files:
effective_mode = "flat"
@@ -533,7 +533,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# Idempotency guard: re-seeding an existing webapp_template
# workspace would clobber the agent's edits (the helper uses
# dirs_exist_ok=True + copytree). If `run.sh` already exists,
# the workspace was seeded on a previous visit skip the file
# the workspace was seeded on a previous visit; skip the file
# copy and only re-derive the frontend port from .env.
from backend.apps.outputs.runtime import _find_free_port, _read_env_value
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
@@ -546,7 +546,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
else:
frontend_port = _find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
# SKILL.md still goes in workspace root agent reads it for
# SKILL.md still goes in workspace root; agent reads it for
# context. Live content (user-editable via Skills page) is
# injected into the system prompt regardless.
with open(os.path.join(folder, "SKILL.md"), "w") as f:
@@ -559,7 +559,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
# the Apps sidebar the moment the user kicks off generation.
# Previously the record only landed when the editor's autosave
# fired, which itself was gated on `files['index.html']` being
# non-empty (a flat-template invariant) meaning React+Vite
# non-empty (a flat-template invariant); meaning React+Vite
# apps that navigated-away mid-build had no way back. The record
# is a thin pointer (name + workspace_id); the workspace itself
# remains the source of truth for the code.
@@ -591,7 +591,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
"already_seeded": already_seeded,
}
# Legacy flat path unchanged.
# Legacy flat path; unchanged.
if body.files:
for rel_path, content in body.files.items():
full_path = os.path.normpath(os.path.join(folder, rel_path))
@@ -693,7 +693,7 @@ async def runtime_restart(workspace_id: str):
from backend.apps.outputs.runtime import manager as runtime_manager
# Restart only if something's attached; otherwise this is a no-op
# silently (a hard-reload click while the runtime was already torn
# down we'd rather not silently respawn an orphan).
# down; we'd rather not silently respawn an orphan).
rt = runtime_manager.get(workspace_id)
if rt:
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
@@ -725,7 +725,7 @@ async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil`
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil` ,
# prefix-string collision rather than path-component containment. Today's
# UUID-format ids make the collision unlikely in practice, but the check
# is one character and immunizes future id schemes.
@@ -931,7 +931,7 @@ async def execute_output(body: OutputExecute):
# HITL gate: collect warnings up front. If the caller hasn't opted
# in via force=True AND the code touches anything outside the safe
# allowlist, return the warnings + the code itself so the UI can
# show a preview dialog. No subprocess is spawned on this path
# show a preview dialog. No subprocess is spawned on this path ,
# zero-cost when warnings exist, identical-to-before when they
# don't.
if not body.force:
+36 -90
View File
@@ -1,16 +1,4 @@
"""Per-workspace persistent backend runtime.
Each App (workspace) has at most one long-running `backend.py` subprocess
managed by `AppRuntime`. Lifetime is reference-counted via the module-level
`manager` singleton: when the first ViewEditor / DashboardViewCard /
TerminalPanel attaches to a workspace, the process is spawned; when the
last detaches, it's terminated. Multiple subscribers share the same
process and the same in-memory log ring buffer.
This replaces the old one-shot `execute_backend_code` model for the
"backend serves real HTTP endpoints" use case. The one-shot path stays
around (see `executor.py`) for legacy `/api/outputs/execute` callers.
"""
"""Per-workspace persistent backend.py runtime; one AppRuntime per workspace, refcounted by manager singleton."""
import asyncio
import logging
@@ -25,70 +13,28 @@ from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Recent log lines kept in memory per runtime. Lets a Terminal tab that
# opens mid-session replay the context that was already printed instead
# of seeing a blank pane. 2000 lines ≈ a few hundred KB at worst —
# bounded and predictable.
# 2000 lines per runtime; lets a Terminal tab opened mid-session replay context. ~few hundred KB at worst.
_LOG_BUFFER_LINES = 2000
# Seconds to wait after SIGTERM before escalating to SIGKILL. Most
# well-behaved Python servers shut down well under a second; this is the
# upper bound before we move on so a wedged process can't block a
# workspace tear-down forever.
# SIGTERM grace; well-behaved servers shut down under a second so 3s is enough.
_TERMINATE_GRACE_SECONDS = 3
# How long we'll wait for Vite (or whatever frontend server bash run.sh
# spawns) to bind on FRONTEND_PORT before giving up and reporting the
# frontend as "not ready." Covers cold-start `npm install` (~60-90s on
# typical hardware for the template's dependency set) plus the Vite
# bind itself. After this we keep the runtime running — the user can
# check the Terminal pane to see what went wrong — but stop blocking
# the preview pane on a port that may never come up.
# 180s covers npm install (60-90s on typical hardware) plus the Vite bind.
_FRONTEND_BIND_TIMEOUT_SECONDS = 180
# Drop from 0.5 → 0.08 because that 500ms window was ENTIRELY user-visible
# preview latency — after Vite actually binds we'd wait up to half a second
# before noticing and emitting runtime:status to the editor. 80ms TCP
# probes are cheap (async open_connection on localhost, no DNS, no
# handshake to a real upstream) and shave the perceived cold-start by
# roughly half a second. The asyncio.open_connection call has its own
# 500ms connect timeout for the failure case so a wedged listener won't
# turn this into a tight CPU loop.
# 80ms probe: dropping from 500ms was pure user-visible preview latency win; cheap on localhost.
_FRONTEND_BIND_POLL_INTERVAL = 0.08
# Process-wide mutex that serializes new-mode workspace boots so only
# ONE vite optimizeDeps run is in flight at a time. Acquired in
# `AppRuntime.start` (new-mode branch only) BEFORE the run.sh spawn,
# released by `_await_frontend_bind` the instant vite emits its
# "frontend ready" log line — or by the timeout / failure paths.
#
# Why a module-level asyncio.Lock and not part of AppRuntimeManager:
# the lock has to be acquired BEFORE the runtime is registered in
# manager.runtimes (which happens inside manager.attach's own
# `_lock`), and we can't hold both locks at once without inviting
# deadlock. Lifting to the module keeps the two locks fully
# independent — the manager lock guards the runtime dict, this one
# guards "is anyone currently mid-MUI-bundle?"
# Module-level lock so only ONE vite optimizeDeps runs at a time; must be acquired before manager._lock to avoid deadlock with manager.attach.
_vite_boot_lock = asyncio.Lock()
# Number of idle (zero-attachment) runtimes the manager keeps alive in
# its LRU before reaping the oldest. Trades memory for instant
# switch-back: clicking a previously-opened App reattaches to an
# already-running vite + uvicorn instead of paying the ~1-2s spawn
# cost. Bumped beyond 1 because the typical "App Builder" user keeps
# 2-3 in-progress apps and ping-pongs between them.
# Idle runtimes kept in LRU; trades memory for instant switch-back, beyond 1 because typical users ping-pong 2-3 apps.
_MAX_IDLE_RUNTIMES = 3
# Cap on recent error lines kept per workspace runtime. The agent only
# needs a snapshot of "what broke since my last write" — older errors
# get dropped. 50 is enough to catch a babel error message + its stack
# trace + a couple of related warnings without bloating the context.
# Cap on recent error lines the agent gets; 50 is enough for babel error + stack + a few warnings.
_RECENT_ERRORS_MAX = 50
# Regex that matches lines we want to surface back to the agent. Picks
# up the common JS/TS/Python build-error formats vite, babel, tsc, and
# uvicorn emit. Kept narrow on purpose so routine info logs and
# deprecation warnings don't pollute the agent's context.
# Narrow regex for build errors (vite, babel, tsc, uvicorn); keeps routine logs out of agent context.
import re as _re
_ERROR_PATTERNS = _re.compile(
r"(?:"
@@ -114,10 +60,10 @@ def _suspend_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
PROCESS GROUP (negative PID) when the child is a session leader,
so vite + uvicorn + their npm/python subchildren all pause together.
No-op on Windows (SIGSTOP has no equivalent the `OpenProcessToken` +
No-op on Windows (SIGSTOP has no equivalent; the `OpenProcessToken` +
`NtSuspendProcess` route works but isn't worth the win32 surface
here; idle Windows runtimes just stay running, which is the current
behavior). Failures here are swallowed if the process already died
behavior). Failures here are swallowed; if the process already died
a stop signal is meaningless."""
if proc is None or os.name == "nt":
return
@@ -126,7 +72,7 @@ def _suspend_process_tree(proc: Optional[asyncio.subprocess.Process]) -> None:
return
os.kill(proc.pid, signal.SIGSTOP)
except (ProcessLookupError, PermissionError, OSError):
# Already-dead or out-of-permission both safe to ignore.
# Already-dead or out-of-permission; both safe to ignore.
pass
@@ -272,7 +218,7 @@ def _write_env_value(env_path: str, key: str, value: str) -> None:
def _is_new_mode(workspace_path: str) -> bool:
"""A workspace is "new-mode" (webapp-template scaffold) if it has a
`run.sh` at its root. Old-mode workspaces are flat `index.html`-only
apps that pre-date the template swap they're served by OpenSwarm's
apps that pre-date the template swap; they're served by OpenSwarm's
own `/api/outputs/workspace/{ws}/serve/...` FastAPI route and have an
optional `backend.py` we spawn directly.
@@ -299,7 +245,7 @@ def _read_env_value(env_path: str, key: str) -> Optional[str]:
if k.strip() != key:
continue
v = v.strip()
# Strip an inline `# comment`. Naive bash semantics are
# Strip an inline `# comment`. Naive; bash semantics are
# more permissive, but values we write don't contain `#`.
if "#" in v:
v = v.split("#", 1)[0].rstrip()
@@ -350,7 +296,7 @@ class AppRuntime:
self.process: Optional[asyncio.subprocess.Process] = None
self.log_buffer: deque[LogLine] = deque(maxlen=_LOG_BUFFER_LINES)
self._subscribers: set[LogSubscriber] = set()
# Recent build/runtime errors scraped from stderr drained by
# Recent build/runtime errors scraped from stderr; drained by
# the agent's post-tool hook after Write/Edit so the agent sees
# vite/babel/uvicorn errors in its next turn and can self-fix
# instead of leaving the user with a red iframe overlay.
@@ -402,7 +348,7 @@ class AppRuntime:
without waiting for the subprocess to print anything.
- **Old-mode** (no `run.sh`): spawn `python -u backend.py` if
present, with `PORT` env var. This is the legacy path
present, with `PORT` env var. This is the legacy path ,
unchanged so flat-index.html apps keep working.
Returns True if a process is running after this call. False is
@@ -432,7 +378,7 @@ class AppRuntime:
ok = await self._start_new_mode()
if not ok:
# Spawn failed before the bind-poll task was
# created release synchronously so we don't
# created; release synchronously so we don't
# wedge the next workspace.
_vite_boot_lock.release()
return ok
@@ -466,7 +412,7 @@ class AppRuntime:
self.frontend_port = new_port
_write_env_value(env_path, "FRONTEND_PORT", str(new_port))
# BACKEND_PORT may be the literal string "NONE" (frontend-only
# app the common case) or a number once `backend_init.sh` has
# app; the common case) or a number once `backend_init.sh` has
# run. Only populate self.port when there's a real backend.
if bp_raw and bp_raw != "NONE":
try:
@@ -518,7 +464,7 @@ class AppRuntime:
self.process = None
return False
backend_note = f" + backend on {self.port}" if self.port else ""
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
self._wait_task = asyncio.create_task(self._await_exit())
@@ -536,7 +482,7 @@ class AppRuntime:
`frontend_url` property reads.
Also responsible for releasing the module-level `_vite_boot_lock`
every exit path (success, process death, hard timeout) MUST
; every exit path (success, process death, hard timeout) MUST
release exactly once so the next queued workspace can start its
own vite spawn. A try/finally on the lock guarantees that even
an exception in the poll body doesn't strand the lock holding."""
@@ -562,7 +508,7 @@ class AppRuntime:
port = self.frontend_port
deadline = asyncio.get_event_loop().time() + _FRONTEND_BIND_TIMEOUT_SECONDS
while asyncio.get_event_loop().time() < deadline:
# Stop polling if the process died pointless to keep
# Stop polling if the process died; pointless to keep
# checking a port nothing will bind.
if self.process is None or self.process.returncode is not None:
return
@@ -583,7 +529,7 @@ class AppRuntime:
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
))
# Release the vite-boot mutex the INSTANT vite is
# ready the next queued workspace can start its
# ready; the next queued workspace can start its
# own bundle now even though we'll keep streaming
# logs for this one.
_release_boot_lock()
@@ -591,12 +537,12 @@ class AppRuntime:
except (OSError, asyncio.TimeoutError):
pass
await asyncio.sleep(_FRONTEND_BIND_POLL_INTERVAL)
# Timed out keep the runtime up (Terminal might show useful
# Timed out; keep the runtime up (Terminal might show useful
# errors) but surface why the preview never appeared.
self._broadcast(LogLine(
"runtime",
f"[runtime] frontend did NOT bind on port {port} after "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s check the Terminal "
f"{_FRONTEND_BIND_TIMEOUT_SECONDS}s; check the Terminal "
f"for npm/vite errors.",
))
finally:
@@ -613,7 +559,7 @@ class AppRuntime:
self.port = _find_free_port()
env = self._spawn_env_base()
env["PORT"] = str(self.port)
env["BACKEND_PORT"] = str(self.port) # alias both common names work
env["BACKEND_PORT"] = str(self.port) # alias; both common names work
try:
# -u forces unbuffered stdout/stderr so the Terminal pane
# sees lines in real time, not whenever Python decides to
@@ -648,7 +594,7 @@ class AppRuntime:
async with self._lock:
if not self.process or self.process.returncode is not None:
# Still cancel the bind poller in case stop() races a
# never-launched runtime defensive no-op otherwise.
# never-launched runtime; defensive no-op otherwise.
if self._frontend_ready_task and not self._frontend_ready_task.done():
self._frontend_ready_task.cancel()
return
@@ -695,7 +641,7 @@ class AppRuntime:
def _broadcast(self, line: LogLine) -> None:
self.log_buffer.append(line)
# Snapshot subscribers they can self-remove during dispatch.
# Snapshot subscribers; they can self-remove during dispatch.
for cb in list(self._subscribers):
try:
cb(line)
@@ -704,7 +650,7 @@ class AppRuntime:
def _maybe_capture_error(self, text: str) -> None:
"""If a stderr/stdout line matches a known build-error pattern,
record it for the next agent-tool drain. Tests every line
record it for the next agent-tool drain. Tests every line ,
cheap (single regex search) and only the matching ones land in
the buffer."""
if _ERROR_PATTERNS.search(text):
@@ -739,7 +685,7 @@ class AppRuntimeManager:
Reference-counts attachments so we don't kill a backend when one
Terminal closes while another is still subscribed. First attach
spawns; final detach moves the runtime into an LRU idle pool
instead of stopping it immediately so re-clicking a recent App
instead of stopping it immediately; so re-clicking a recent App
is instant. The oldest runtime gets reaped once the pool exceeds
_MAX_IDLE_RUNTIMES."""
@@ -755,14 +701,14 @@ class AppRuntimeManager:
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
revived = False
# Defined here so every code path below leaves it bound the
# Defined here so every code path below leaves it bound; the
# revive-idle branch used to skip the assignment, leaving the
# post-lock `if dead is not None:` check throwing UnboundLocalError.
dead: Optional[AppRuntime] = None
async with self._lock:
rt = self.runtimes.get(workspace_id)
if rt is None:
# Maybe the runtime is sitting idle in the LRU revive
# Maybe the runtime is sitting idle in the LRU; revive
# it without paying the spawn cost again.
idle_rt = self._idle_lru.pop(workspace_id, None)
if idle_rt is not None and idle_rt.running:
@@ -775,7 +721,7 @@ class AppRuntimeManager:
_resume_process_tree(rt.process)
else:
if idle_rt is not None:
# Stale idle entry process died while idling.
# Stale idle entry; process died while idling.
# Drop and spawn a fresh one below; old one
# gets stopped outside the lock.
dead = idle_rt
@@ -784,7 +730,7 @@ class AppRuntimeManager:
else:
# Workspace paths shouldn't change for a given id, but if
# somehow they did (e.g. the user moved the workspace
# folder), trust the latest caller they have the
# folder), trust the latest caller; they have the
# current truth.
rt.workspace_path = workspace_path
self._attached[workspace_id] = self._attached.get(workspace_id, 0) + 1
@@ -811,7 +757,7 @@ class AppRuntimeManager:
if rt is None:
return
# If the process is already dead, no point keeping it
# around just clean up. Otherwise move to the LRU AND
# around; just clean up. Otherwise move to the LRU AND
# SIGSTOP the process tree so it consumes 0% CPU while
# idle. The matching SIGCONT lives in attach() above.
if not rt.running:
@@ -853,7 +799,7 @@ class AppRuntimeManager:
"""If `file_path` falls under one of the live workspace
runtimes' workspace_path, drain that workspace's recent
build/runtime errors. Returns [] if no workspace owns the path
or no errors are queued caller can treat empty as 'all clear'.
or no errors are queued; caller can treat empty as 'all clear'.
Used by agent_manager's post-tool hook so the agent sees vite /
babel / uvicorn errors right after a Write/Edit completes."""
if not file_path:
@@ -862,7 +808,7 @@ class AppRuntimeManager:
abs_path = os.path.abspath(file_path)
except Exception:
return []
# Walk both active and idle runtimes the user might have
# Walk both active and idle runtimes; the user might have
# navigated away from the workspace mid-build, but the agent
# could still be editing files; the LRU keeps the runtime alive
# for ~3 idle slots.
+1 -5
View File
@@ -1,5 +1 @@
"""(Reserved for future use; intentionally empty.)
The service-sync layer ships opaque payload dicts through `submit()` —
no Pydantic shape exposed in the public repo.
"""
"""Reserved; service-sync ships opaque payload dicts via submit(), no Pydantic shape exposed."""
+12 -59
View File
@@ -1,8 +1,4 @@
"""Centralized credential resolution for LLM API calls.
Supports multiple providers: Anthropic (native), OpenAI, Gemini,
OpenRouter, and user-configured custom providers.
"""
"""Resolve LLM credentials for the configured provider."""
from __future__ import annotations
@@ -26,18 +22,14 @@ def _check_9router() -> bool:
def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
"""Raise ValueError if credentials are missing for the given provider.
Allows through if 9Router is running as a fallback.
Handles both display names ('Anthropic') and lowercase ('anthropic').
"""
"""Raise ValueError if the provider has no usable credentials."""
p = provider.lower().strip()
# 9Router-backed providers don't need traditional credentials
# 9Router handles its own credentials.
if p == "9router":
return
# If 9Router is running, all providers are accessible
# 9Router proxies every provider, so if it's up we don't need keys here.
if _check_9router():
return
@@ -62,21 +54,20 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
return
raise ValueError("OpenRouter API key not configured. Set it in Settings.")
elif p in ("xai", "meta", "deepseek", "mistral", "qwen", "cohere"):
# These route through OpenRouter — need either OpenRouter key or 9Router
# These providers route through OpenRouter, so its key is required.
if getattr(settings, "openrouter_api_key", None):
return
raise ValueError(f"{provider} requires an OpenRouter API key, or connect a subscription via 9Router.")
else:
# Custom provider — check if it exists in custom_providers
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
return
# Unknown provider — allow through (create_provider will handle the error)
# Let create_provider raise for unknown providers; not our job here.
return
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
"""Return credential dict for a specific provider."""
"""Return the credential dict for the given provider."""
p = provider.lower().strip()
validate_credentials(settings, provider)
@@ -97,45 +88,17 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
if p == "openrouter":
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name.lower() == p:
# Substitute a placeholder when the user left api_key blank
# — local OpenAI-compatible servers (LM Studio, Ollama, etc.)
# ignore the Bearer header but downstream callers may insist
# on non-empty values.
# Local OpenAI-compatible servers (LM Studio, Ollama) ignore the key; placeholder keeps downstream callers happy.
key = (cp.api_key or "").strip() or "no-auth-required"
return {"api_key": key, "base_url": cp.base_url}
raise ValueError(f"No credentials for provider: {provider}")
# ---------------------------------------------------------------------------
# Legacy helpers (kept for backward compat during migration)
# ---------------------------------------------------------------------------
def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""Return the env dict for ClaudeAgentOptions based on connection mode.
DEPRECATED: Use create_provider() from providers.registry instead.
"""
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return a configured AsyncAnthropic client based on connection mode.
Priority: managed mode → 9Router subscription → API key
"""
"""Return an AsyncAnthropic client for the user's current connection mode."""
import anthropic
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
@@ -145,11 +108,11 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
base_url=proxy_url,
)
# Prefer API key when set
# Prefer the user's own API key when present.
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
# Fall back to 9Router subscription (free for users with Claude/ChatGPT/Gemini subscriptions)
# Fall back to 9Router (free for users with Claude/ChatGPT/Gemini subscriptions).
if _check_9router():
return anthropic.AsyncAnthropic(
api_key="9router",
@@ -160,17 +123,7 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
def get_anthropic_client_for_model(settings: AppSettings, api_model: str) -> anthropic.AsyncAnthropic:
"""Return a client configured for the given resolved model id.
When api_model carries a 9Router prefix (cc/, cx/, gc/, cp-), the client
targets 9Router directly — even if connection_mode is openswarm-pro. This
is what lets pinned-route models like "sonnet-cc" actually reach the
user's own subscription instead of getting sent through the managed proxy
with an unrecognizable model id. cp- is the prefix we use when registering
user-configured custom OpenAI-compatible providers in 9Router.
Otherwise delegates to get_anthropic_client() for the default mode-driven
routing.
"""
"""Route 9Router-prefixed models (cc/, cx/, gc/, cp-) straight to 9Router so user subscriptions reach their own accounts."""
import anthropic
if isinstance(api_model, str) and (
api_model.startswith(("cc/", "cx/", "gc/")) or api_model.startswith("cp-")
+18 -40
View File
@@ -4,27 +4,27 @@ from typing import Optional, Any, Literal
DEFAULT_SYSTEM_PROMPT = (
"You are a personal AI assistant running inside OpenSwarm.\n\n"
"## Core Behavior\n"
"Act, don't ask. When a tool can accomplish the task, call it immediately "
"Act, don't ask. When a tool can accomplish the task, call it immediately; "
"do not describe what you would do, do not ask for confirmation, just execute. "
"The user expects results, not plans.\n"
"If ANY available tool is relevant to the user's request, use it. Never respond "
'with "I can do X for you" or "Would you like me to..." just do it. '
'with "I can do X for you" or "Would you like me to..."; just do it. '
"A tool call is always better than a text explanation of what the tool would do.\n"
"For multi-step tasks, chain tool calls in sequence don't stop after one step "
"For multi-step tasks, chain tool calls in sequence; don't stop after one step "
"to ask if you should continue. Complete the entire task, then report the results.\n"
"Be adaptable. If one approach fails, try a different tool or strategy instead of "
"giving up or repeating the same action. Always stay focused on what the user "
"actually wants to accomplish their intent matters more than the specific method.\n\n"
"actually wants to accomplish; their intent matters more than the specific method.\n\n"
"## Tool Priority\n"
"1. Connected MCP tools fastest and most reliable. Use ToolSearch to discover "
"1. Connected MCP tools; fastest and most reliable. Use ToolSearch to discover "
"what integrations are available if you're unsure.\n"
"2. WebSearch / WebFetch for general web lookups when no MCP tool fits.\n"
"3. BrowserAgent last resort, only for visual interaction with websites, "
"2. WebSearch / WebFetch; for general web lookups when no MCP tool fits.\n"
"3. BrowserAgent; last resort, only for visual interaction with websites, "
"filling forms, or tasks no other tool can handle.\n\n"
"## Style\n"
"Do not narrate routine tool calls just call the tool.\n"
"Do not narrate routine tool calls; just call the tool.\n"
"After tool calls complete, present the results directly. Do not recap which "
"tools you called or why the user can see tool calls in the UI.\n"
"tools you called or why; the user can see tool calls in the UI.\n"
"Keep responses brief and direct. Use plain language.\n"
"If you genuinely need clarification on something ambiguous, use the "
"AskUserQuestion tool. Never ask questions inline in plain text.\n"
@@ -40,61 +40,39 @@ class AppSettings(BaseModel):
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
zoom_sensitivity: float = 50.0
theme: str = "dark"
# App Builder workspaces seed a React template that ships with its own
# theme toggle ("Light" / "Dark" at the bottom of the sidebar). By
# default the template should follow the user's OS appearance; once
# the user explicitly toggles it inside any one app the override
# persists across every subsequently-built app via this field
# (the template fetches /api/settings on mount and PUTs back here on
# toggle, so the preference is shared even though each app runs from
# its own vite port / localStorage origin).
# null = follow system / no override; 'light' or 'dark' = sticky.
# Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system.
app_template_theme_override: Optional[Literal["light", "dark"]] = None
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
# Multi-provider API keys
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
custom_providers: list["CustomProvider"] = Field(default_factory=list)
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
allow_experimental_updates: bool = False
# Subscription tokens (from CLI tools, alternative to API keys)
claude_subscription_token: Optional[str] = None
openai_subscription_token: Optional[str] = None
gemini_subscription_token: Optional[str] = None
# User profile (collected during onboarding)
user_name: Optional[str] = None
user_email: Optional[str] = None
user_use_case: Optional[str] = None
user_referral_source: Optional[str] = None
# Per-MCP dismissal map for the preflight suggestion modal. Keyed by
# the curated ToolDefinition.name (e.g. "Google Workspace"); value is
# an ISO timestamp of dismissal. Used by mcp_preflight._build_available_shortlist
# to suppress suggestions the user has explicitly waved off.
# Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp.
dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict)
# Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True
installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
# OpenSwarm Pro subscription
connection_mode: str = "own_key" # "own_key" | "openswarm-pro"
first_opened_at: Optional[str] = None
connection_mode: str = "own_key"
openswarm_bearer_token: Optional[str] = None
openswarm_proxy_url: Optional[str] = None # default resolved in credentials.py
openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra"
openswarm_subscription_expires: Optional[str] = None # ISO 8601
openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at}
# Identity (v1.0.29+). Populated after a successful sign-in via the cloud's
# /api/auth/signin-activate endpoint (Google OAuth or email magic link).
# Stripe checkout also populates these because the cloud's bearer-mint
# always returns user info. Distinct from user_email above which was
# historically a self-reported onboarding field — the values agree once
# sign-in completes (server-validated wins).
openswarm_proxy_url: Optional[str] = None
openswarm_subscription_plan: Optional[str] = None
openswarm_subscription_expires: Optional[str] = None
openswarm_usage_cached: Optional[dict] = None
# Server-validated identity from /api/auth/signin-activate; user_email above is the self-reported onboarding value.
user_id: Optional[str] = None
signin_method: Optional[Literal["google", "stripe", "email"]] = None
+11 -54
View File
@@ -37,10 +37,7 @@ async def settings_lifespan():
import asyncio as _asyncio
async def _boot_router_then_sync():
"""Start 9Router (if any apikey-routed provider is configured)
then push our key-based connections into it. Sequential because
sync_* helpers no-op when 9Router isn't running yet — running
them post-boot guarantees the connections actually land."""
"""Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot)."""
needs_router = any([
getattr(s, "google_api_key", None),
getattr(s, "openai_api_key", None),
@@ -76,13 +73,7 @@ settings = SubApp("settings", settings_lifespan)
def _migrate_legacy_fields(raw: dict) -> dict:
"""Translate deprecated field names/values so they survive into the new schema.
Pre-launch scaffolding used `connection_mode="managed"` and
`openswarm_auth_token`; production names are `"openswarm-pro"` and
`openswarm_bearer_token`. Zero known users are affected, but keep the
mapping for safety.
"""
"""Translate deprecated pre-launch field names ('managed', 'openswarm_auth_token') into production schema."""
if raw.get("connection_mode") == "managed":
raw["connection_mode"] = "openswarm-pro"
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
@@ -102,26 +93,19 @@ def load_settings() -> AppSettings:
return AppSettings()
# Single threading.Lock guards every write to SETTINGS_FILE — protects against
# corruption from two requests racing through the file system. Async callers
# offload the actual write to the default thread pool (run_in_executor), so
# the lock works for both sync and thread-pool execution paths.
# threading.Lock guards every SETTINGS_FILE write; works for sync paths and async run_in_executor paths.
_settings_write_lock = threading.Lock()
def _atomic_write_settings(payload: dict) -> None:
"""Internal: serialise payload to SETTINGS_FILE atomically.
Always called via save_settings* — don't invoke directly."""
"""Atomic SETTINGS_FILE write; call via save_settings*, not directly."""
with _settings_write_lock:
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
# On Windows, os.replace can transiently fail with PermissionError
# if Defender or another reader holds the destination open. One
# retry after a short backoff handles every real-world case
# without masking genuine permission bugs.
# Windows: Defender can briefly lock the destination; one retry handles every real case.
for attempt in range(2):
try:
os.replace(tmp, SETTINGS_FILE)
@@ -139,24 +123,17 @@ def _atomic_write_settings(payload: dict) -> None:
def save_settings(settings_obj: AppSettings) -> None:
"""Synchronously persist settings atomically. Thread-safe.
Use from sync paths (analytics collector, lifespans). Async callers should
prefer save_settings_async to avoid blocking the event loop on Windows
where Defender scans can stretch the write to 50-200ms."""
"""Sync atomic persist; thread-safe. Async callers should prefer save_settings_async (Defender can stretch writes to 50-200ms)."""
_atomic_write_settings(settings_obj.model_dump())
async def save_settings_async(settings_obj: AppSettings) -> None:
"""Async-safe atomic save. Runs the file I/O in the default thread pool
so the FastAPI event loop stays responsive while the write completes.
Shares the threading.Lock with the sync variant for safe interleaving."""
"""Async atomic save via thread pool; shares the lock with the sync variant."""
payload = settings_obj.model_dump()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _atomic_write_settings, payload)
# Backward-compat alias. Existing sync callers (analytics collector, analytics
# lifespan) continue to work; new async callers should use save_settings_async.
def _save_settings(settings_obj: AppSettings) -> None:
save_settings(settings_obj)
@@ -172,14 +149,12 @@ async def update_settings(body: AppSettings):
old = load_settings()
# Sync the settings state (secrets stripped).
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"openswarm_bearer_token", "installation_id"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
_sync(safe)
# Identify user in service-sync when profile is set/changed
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
(body.user_name and body.user_name != getattr(old, "user_name", None)):
from backend.apps.service.client import identify as _identify
@@ -227,8 +202,7 @@ async def update_settings(body: AppSettings):
except Exception:
pass
# Boot+sync runs off the request path ensure_running() can take 5min
# on first install (npm pull) and would freeze the event loop.
# Off the request path: ensure_running() can take 5min on first install (npm pull) and would freeze the loop.
if google_changed or openai_changed or openrouter_changed or custom_providers_changed:
async def _boot_and_sync_keys(
google_key: str | None,
@@ -275,12 +249,7 @@ async def update_settings(body: AppSettings):
any_keyed_added,
))
# When openswarm-pro mode or bearer token changes, register a `claude`
# apikey connection in 9Router that proxies through our cloud. This
# makes the CLI's built-in WebSearch work on non-Claude primaries for
# Pro users — the CLI's Anthropic delegation path now has a working
# Claude route via 9Router, instead of hitting "no credentials for
# provider: claude".
# On pro-mode/bearer change, register a `claude` apikey connection in 9Router so CLI WebSearch works on non-Claude primaries.
pro_mode_old = getattr(old, "connection_mode", None) == "openswarm-pro"
pro_mode_new = getattr(body, "connection_mode", None) == "openswarm-pro"
bearer_old = getattr(old, "openswarm_bearer_token", None)
@@ -305,25 +274,13 @@ class AppThemeOverridePayload(BaseModel):
@settings.router.get("/app-theme-override")
async def get_app_theme_override():
"""Cross-app theme preference for App Builder workspaces.
Returns the current override (or `null` for follow-system). Apps
served from the template fetch this on mount so a toggle inside
any one app sticks across every future app the user builds. Each
app workspace runs on its own vite port (separate localStorage
origin), so the backend is the only place this can live."""
"""Cross-app theme preference for App Builder workspaces; backend-held because each app uses its own localStorage origin."""
return {"mode": load_settings().app_template_theme_override}
@settings.router.put("/app-theme-override")
async def put_app_theme_override(body: AppThemeOverridePayload):
"""MERGE the theme override into AppSettings. The general PUT
/api/settings endpoint replaces the whole AppSettings object —
sending a partial body there would default every secret-bearing
field (api keys, subscription tokens), which logs the user out
and pops the SignInGate. This dedicated endpoint mutates only
`app_template_theme_override` and leaves every other field
untouched."""
"""MERGE the override; the general PUT /api/settings replaces the whole object and would blank secrets, logging the user out."""
current = load_settings()
current.app_template_theme_override = body.mode
await save_settings_async(current)
+1 -5
View File
@@ -10,11 +10,7 @@ class Skill(BaseModel):
content: str
file_path: str = ""
command: str = ""
# Skills that OpenSwarm ships as part of the platform (e.g. the App
# Builder reference) get this flag set. The UI hides the delete
# button for them and the DELETE endpoint refuses with 409. Content
# is still editable — the whole point is that users can tune how
# the platform-internal agents behave.
# Platform-shipped skills (e.g. App Builder): UI hides delete and DELETE returns 409, but content stays editable so users can tune them.
built_in: bool = False
+6 -65
View File
@@ -20,7 +20,6 @@ import {
import AppShell from './components/Layout/AppShell';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import ErrorBoundary from './components/ErrorBoundary';
// Lazy: heavy pages that aren't on the first-paint path.
const Skills = lazy(() => import('./pages/Skills/Skills'));
const Tools = lazy(() => import('./pages/Tools/Tools'));
const Modes = lazy(() => import('./pages/Modes/Modes'));
@@ -32,20 +31,7 @@ const OnboardingRoot = lazy(() =>
);
const SignInGate = lazy(() => import('./components/SignInGate'));
// Idle-prefetch the lazy page chunks so first-click on any sidebar
// entry doesn't pay 200-600ms for the webpack chunk download. Each
// `void import('...')` triggers webpack to stream the chunk in the
// background; React.lazy returns the cached module instantly when the
// user finally navigates. We do them sequentially inside one idle
// callback to avoid all six firing at once and contending for network
// + parse time during first paint.
if (typeof window !== 'undefined') {
// Map sidebar paths to their dynamic imports so a hover/mouseenter on
// the sidebar can preload the chunk before the click. By the time the
// user actually clicks (~100-300ms after hover), the chunk is parsed
// and React.lazy resolves instantly. Exposed on window so AppShell
// can call it without prop-drilling. Each entry is idempotent;
// webpack dedupes repeated dynamic imports.
(window as any).__openswarmPrefetchRoute = (path: string) => {
switch (path) {
case '/skills': void import('./pages/Skills/Skills'); return;
@@ -66,12 +52,6 @@ if (typeof window !== 'undefined') {
void import('./pages/Customization/Customization');
void import('./pages/Analytics/Analytics');
};
// Tighter idle deadline (was 4000ms): we WANT these chunks loaded
// before the user's first click, so don't let the browser defer them
// indefinitely. Fallback timeout reduced from 2000ms to 500ms for the
// same reason. The cost during initial render is small (one chunk
// parse per route, deferred); the cost of paying it on first click
// is a multi-hundred-ms freeze.
const ric = (window as any).requestIdleCallback as
| ((cb: () => void, opts?: { timeout?: number }) => number)
| undefined;
@@ -216,11 +196,7 @@ 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.
useInteractionHeartbeat();
return <>{children}</>;
};
@@ -234,23 +210,13 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
// missed webhook (cancel, upgrade, renewal) can't leave the user
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
// we simply keep whatever local state we already had.
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => { /* offline — next launch will reconcile */ });
.catch(() => {});
}, [dispatch]);
// Refetch settings when the window regains focus. Catches every out-of-
// band settings mutation that doesn't come through a renderer-dispatched
// thunk: Stripe checkout's bearer-handoff page POSTing /api/subscription/
// activate, the new sign-in flow's bearer-handoff POSTing /api/auth/
// signin-activate, manual ~/.openswarm/settings.json edits, etc. Throttled
// by the browser's natural focus cadence (one refetch per Cmd-Tab back).
useEffect(() => {
const onFocus = () => { dispatch(fetchSettings()); };
window.addEventListener('focus', onFocus);
@@ -268,15 +234,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
// Sign-in gate. Sits between SettingsLoader and DefaultModelGuard so the
// gate is the very first thing a user without a user_id sees.
//
// 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.
/** Mandatory sign-in gate; first thing shown when settings lack a user_id or bearer. */
const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
@@ -284,10 +242,6 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children })
const alreadySignedIn = Boolean(settings.user_id || settings.openswarm_bearer_token);
// 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;
const id = setInterval(() => { dispatch(fetchSettings()); }, 2000);
@@ -307,10 +261,6 @@ const SignInGateLoader: React.FC<{ children: React.ReactNode }> = ({ children })
);
};
// Priority order for picking a default model when the user's stored
// default_model is unreachable (no matching provider connected). The user's
// preferred fallback ordering: direct provider keys first, then OpenSwarm
// Pro, then Copilot-powered OpenSwarm free tier.
const DEFAULT_MODEL_PRIORITY: string[] = [
'Anthropic',
'OpenAI',
@@ -319,9 +269,6 @@ const DEFAULT_MODEL_PRIORITY: string[] = [
'OpenSwarm',
];
// Preferred model pick inside each provider group. Ordered by the user's
// stated preference: Sonnet mid-tier for Claude, GPT-5.4 Mini for OpenAI,
// Flash for Gemini, and conservative picks for the shared tiers.
const DEFAULT_MODEL_PICKS: Record<string, string[]> = {
Anthropic: ['sonnet-cc', 'sonnet'],
OpenAI: ['gpt-5.4-mini', 'gpt-5.4'],
@@ -348,10 +295,7 @@ function pickFallbackModel(
return null;
}
// Reconciles the stored default_model against the set of models actually
// reachable given the user's current connections. When the stored value is
// unavailable, falls back per DEFAULT_MODEL_PRIORITY and shows a one-time
// warning so the user knows why their default changed.
/** Reconciles stored default_model against reachable models; falls back per DEFAULT_MODEL_PRIORITY and warns once. */
const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
@@ -399,7 +343,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
sx={{ fontSize: '0.8rem' }}
>
{warning && (
<>Default model <b>{warning.from}</b> is no longer available switched to <b>{warning.to}</b> ({warning.provider}).</>
<>Default model <b>{warning.from}</b> is no longer available, switched to <b>{warning.to}</b> ({warning.provider}).</>
)}
</Alert>
</Snackbar>
@@ -491,9 +435,7 @@ const ThemedApp: React.FC = () => {
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
{/* Dashboard renders persistently in AppShell so webviews survive nav. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
@@ -520,8 +462,7 @@ const ThemedApp: React.FC = () => {
);
};
// Tiny mount-point so the route-tracker hook can use useLocation() (which
// requires a Router ancestor). Lives inside HashRouter, runs once.
// useRouteTracker calls useLocation, must be inside HashRouter.
const RouteTrackerMount: React.FC = () => {
useRouteTracker();
return null;
+6 -90
View File
@@ -34,10 +34,6 @@ import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToo
import GlobalSearchPalette from '@/app/components/GlobalSearchPalette';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded';
interface SessionApprovalGroup {
@@ -61,17 +57,9 @@ const STATUS_CONFIG: Record<string, { label: string; tokenKey?: string }> = {
stopped: { label: 'Stopped', tokenKey: 'info' },
};
// ---------------------------------------------------------------------------
// Spring configs
// ---------------------------------------------------------------------------
const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 };
const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 };
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const StatusDot: React.FC<{ status: string; c: ReturnType<typeof useClaudeTokens> }> = ({ status, c }) => {
const cfg = STATUS_CONFIG[status];
const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
@@ -172,10 +160,6 @@ const AgentStatusRow: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Compact activity indicator — subtle breathing dot
// ---------------------------------------------------------------------------
const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<Box
sx={{
@@ -193,20 +177,7 @@ const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = (
/>
);
// ---------------------------------------------------------------------------
// Memoized session projection
// ---------------------------------------------------------------------------
//
// DynamicIsland only reads name / status / dashboard_id / pending_approvals
// per session. We project to a stable shape so identity persists across
// streamingMessage deltas (which mutate state.streaming, not state.agents,
// but still trigger Immer to swap the agents root reference any time
// agentsSlice runs (fine in theory, but selector consumers re-fire).
//
// Per-session cache: when a session's relevant fields haven't moved,
// return the SAME inner object reference, so the outer dict can be
// dropped on shallowEqual if its key set + per-session refs match.
// Memoized session projection so identity persists across streamingMessage deltas; shallowEqual works.
type DiSession = {
id: string;
name: string;
@@ -245,8 +216,7 @@ const selectDynamicIslandSessions = createSelector(
out[sid] = next;
}
}
// Evict cache entries for sessions that disappeared. Without this,
// long sessions of dashboard switching slowly accumulate dead refs.
// Evict cache entries for vanished sessions or refs accumulate during dashboard switching.
for (const cached of _diSessionCache.keys()) {
if (!liveIds.has(cached)) _diSessionCache.delete(cached);
}
@@ -254,26 +224,13 @@ const selectDynamicIslandSessions = createSelector(
},
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DynamicIsland: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const islandRef = useRef<HTMLDivElement>(null);
// Read the whole sessions dict, but memoize its projection so the
// useSelector only emits a new value when one of the four fields we
// actually consume (name/status/dashboard_id/pending_approvals)
// changes for SOME session. createSelector caches both the inner
// per-session shape AND the outer dict, so re-runs return the same
// reference when nothing relevant moved, even though Immer flips
// the top-level dict ref on every streamed character elsewhere.
// shallowEqual: createSelector returns a fresh outer dict object on
// each re-run, but the inner refs are cached so when nothing relevant
// moved, key-by-key comparison short-circuits the re-render.
// Memoized projection + shallowEqual; only re-renders when one of the four fields actually changes.
const sessions = useAppSelector(selectDynamicIslandSessions, shallowEqual);
const history = useAppSelector((state) => state.agents.history, shallowEqual);
const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds, shallowEqual);
@@ -281,7 +238,7 @@ const DynamicIsland: React.FC = () => {
const [userExpanded, setUserExpanded] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Global Cmd/Ctrl+K open search palette from anywhere.
// Global Cmd/Ctrl+K opens search palette from anywhere.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
@@ -293,24 +250,13 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, []);
// Global Cmd/Ctrl+L clear the chat (Claude Code convention). Resolves
// the target session in priority order:
// 1) the session whose chat input currently has focus (when typing inside
// a contentEditable card body, the data-session-id climbs the DOM)
// 2) state.agents.activeSessionId (last touched chat)
// 3) a single visible session if there's exactly one
// No-op if none of those resolve. Hits the same /clear endpoint as the
// /clear slash command and dispatches clearSessionMessages so the visible
// transcript matches the now-empty SDK context.
// Cmd/Ctrl+L: clear the chat (focused card > activeSessionId > sole session); same as /clear.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.shiftKey || e.altKey) return;
if (e.key.toLowerCase() !== 'l') return;
// Walk up from activeElement looking for an agent-card marker.
// Falls back to Redux's activeSessionId, then to the only session
// if it's unambiguous.
let target: string | null = null;
const ae = document.activeElement as HTMLElement | null;
if (ae) {
@@ -347,8 +293,6 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, [dispatch]);
// ---- Derived data ----
const groups: SessionApprovalGroup[] = useMemo(() => {
const result: SessionApprovalGroup[] = [];
for (const [sessionId, session] of Object.entries(sessions)) {
@@ -429,8 +373,6 @@ const DynamicIsland: React.FC = () => {
);
}, [groups]);
// ---- Island state machine ----
const islandState: IslandState = useMemo(() => {
if (userExpanded && (hasAgents || hasApprovals)) return 'expanded';
if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded';
@@ -445,8 +387,6 @@ const DynamicIsland: React.FC = () => {
}
}, [hasAgents, hasApprovals]);
// ---- Click outside to collapse ----
useEffect(() => {
if (islandState !== 'expanded') return;
const handler = (e: MouseEvent) => {
@@ -458,8 +398,6 @@ const DynamicIsland: React.FC = () => {
return () => document.removeEventListener('mousedown', handler);
}, [islandState]);
// ---- Callbacks ----
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
@@ -524,8 +462,6 @@ const DynamicIsland: React.FC = () => {
}
}, [islandState]);
// ---- Styling — uses the same neutral palette as the rest of the UI ----
const islandWidth = islandState === 'idle'
? 200
: islandState === 'compact'
@@ -542,8 +478,6 @@ const DynamicIsland: React.FC = () => {
? c.shadow.sm
: c.shadow.md;
// ---- Compact summary text ----
const compactText = useMemo(() => {
const parts: string[] = [];
if (activeAgents.length > 0) {
@@ -562,8 +496,6 @@ const DynamicIsland: React.FC = () => {
}
`, [c.status.warning]);
// ---- Render ----
return (
<>
{islandState === 'compact-actionable' && <style>{glowKeyframes}</style>}
@@ -655,10 +587,6 @@ const DynamicIsland: React.FC = () => {
);
};
// ---------------------------------------------------------------------------
// Idle pill — clickable search bar (opens GlobalSearchPalette).
// ---------------------------------------------------------------------------
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const SEARCH_HOTKEY = isMac ? '⌘K' : 'Ctrl+K';
@@ -713,10 +641,6 @@ const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens>; onClick: () =>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact pill
// ---------------------------------------------------------------------------
const CompactPill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
text: string;
@@ -769,10 +693,6 @@ const CompactPill: React.FC<{
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — single approval with icon + name + approve/deny
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
request: ApprovalRequest;
@@ -854,7 +774,7 @@ const CompactActionablePill: React.FC<{
+{remainingCount - 1}
</Typography>
)}
<Tooltip title={isIntervention ? 'Done continue' : 'Approve'} arrow>
<Tooltip title={isIntervention ? 'Done, continue' : 'Approve'} arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onApprove(request.id); }}
@@ -926,10 +846,6 @@ const CompactActionablePill: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Expanded card
// ---------------------------------------------------------------------------
const ExpandedCard: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
groups: SessionApprovalGroup[];
@@ -71,10 +71,7 @@ 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.
// Drag-select also emits agent:attached_to_browser; addElementForOwner alone misses this path.
if (el.semanticType === 'browser-card' || el.semanticType === 'agent-card') {
onboardingBus.emit('agent:attached_to_browser');
}
@@ -115,11 +112,7 @@ 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.
// Onboarding steps 5/6 wait on agent:attached_to_browser; both kinds resolve the same wait.
if (
el.semanticType === 'browser-card' ||
el.semanticType === 'agent-card'
+5 -13
View File
@@ -2,11 +2,11 @@ import React from 'react';
import { report, getRecentActions } from '@/shared/serviceClient';
interface Props {
/** Friendly title for the fallback card. Default: "Something broke." */
/** Title for the fallback card. */
title?: string;
/** Optional reset hook — if provided, the Reload button calls this instead of reloading the window. */
/** If provided, Reload calls this instead of reloading the window. */
onReset?: () => void;
/** Where the boundary lives, for support ("root" | "page:tools" | etc.). */
/** Where the boundary lives, for support ("root", "page:tools", etc.). */
scope?: string;
children: React.ReactNode;
}
@@ -15,11 +15,7 @@ interface State {
error: Error | null;
}
/**
* Catches uncaught render errors so a single broken component doesn't
* black out the whole app. Stack stays visible so users can copy/paste
* it to support; the cloud gets a fire-and-forget operational report.
*/
/** Catches uncaught render errors; fallback shows stack, cloud gets a fire-and-forget report. */
class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
@@ -34,12 +30,9 @@ class ErrorBoundary extends React.Component<Props, State> {
message: String(error?.message || error).slice(0, 500),
stack: String(error?.stack || '').slice(0, 2000),
component_stack: String(info?.componentStack || '').slice(0, 2000),
// Last 10 user-surface actions before the boundary tripped, so the
// backend can correlate the crash with what the user just did.
recent_actions: getRecentActions(10),
});
} catch {}
// surface in dev so developers can read the stack
if (typeof console !== 'undefined' && console.error) {
console.error('[ErrorBoundary]', error, info);
}
@@ -55,7 +48,6 @@ class ErrorBoundary extends React.Component<Props, State> {
};
handleResetState = () => {
// best-effort: clear any localStorage we own + reload
try {
const keys = Object.keys(localStorage);
for (const k of keys) {
@@ -127,7 +119,7 @@ class ErrorBoundary extends React.Component<Props, State> {
<div style={card}>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600 }}>{title}</h2>
<p style={{ margin: '0 0 16px', color: '#9c9a92', fontSize: 14, lineHeight: 1.5 }}>
We caught it before it crashed everything. The error is below copy it
We caught it before it crashed everything. The error is below; copy it
if you want to share. Reload usually fixes it.
</p>
<div>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
/** Cute slime with × eyes and a red error badge error / warning illustration. */
/** Slime illustration with X eyes and red badge for errors/warnings. */
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
<path
@@ -50,7 +50,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
const searchLoading = useAppSelector((s) => s.agents.historySearch.loading);
const searchQuery = useAppSelector((s) => s.agents.historySearch.query);
// Debounced session/history search.
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -62,7 +61,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
};
}, [query, open, dispatch]);
// Reset on open + autofocus.
useEffect(() => {
if (open) {
setQuery('');
@@ -75,9 +73,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
setSelectedIndex(0);
}, [query]);
// Build results: dashboards first, then sessions. Sessions come from
// `historySearch.results` (closed) plus active in-memory sessions
// (not in history yet).
// Dashboards then sessions; merges in-memory active sessions with historySearch.results.
const results = useMemo<Result[]>(() => {
const q = query.trim().toLowerCase();
const dashboardResults: DashboardResult[] = Object.values(dashboards)
@@ -86,9 +82,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
.slice(0, 5)
.map((d) => ({ kind: 'dashboard', id: d.id, name: d.name }));
// Merge active in-memory sessions with history search results, dedupe by id.
const sessionMap = new Map<string, SessionResult>();
// Active in-memory sessions
for (const s of Object.values(sessions)) {
if (q && !(s.name || '').toLowerCase().includes(q)) continue;
sessionMap.set(s.id, {
@@ -100,8 +94,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
closedAt: null,
});
}
// When the query is empty, fall back to recent history rather than the
// (potentially huge) history dump — matches what the user sees on init.
// Empty query falls back to recent history, not the full dump.
const historyPool: HistorySession[] = q ? searchResults : Object.values(history).slice(0, 20);
for (const h of historyPool) {
if (sessionMap.has(h.id)) continue;
@@ -123,13 +116,10 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (r.kind === 'dashboard') {
navigate(`/dashboard/${r.id}`);
} else {
// Session: navigate to its dashboard (if any), focus the card.
// For closed sessions, resume first so the card can render.
if (r.dashboardId) {
navigate(`/dashboard/${r.dashboardId}`);
if (r.closedAt) {
// Closed history session — resume so it lands back in `sessions`
// and the dashboard layout can place a card for it.
// Closed history: resume so it lands in `sessions` and layout can place a card.
dispatch(resumeSession({ sessionId: r.id })).then(() => {
dispatch(setPendingFocusAgentId(r.id));
});
@@ -137,9 +127,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
dispatch(setPendingFocusAgentId(r.id));
}
} else if (r.closedAt) {
// No dashboard — just resume; the resumed session will land in some
// dashboard if it had one, otherwise it'll be orphan and we can't
// really "navigate" anywhere meaningful.
// Orphan closed session: resume; we can't navigate anywhere meaningful.
dispatch(resumeSession({ sessionId: r.id }));
}
}
@@ -164,11 +152,9 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (!open) return null;
// Group results visually. Sections collapse if empty.
const dashSection = results.filter((r): r is DashboardResult => r.kind === 'dashboard');
const sessSection = results.filter((r): r is SessionResult => r.kind === 'session');
// Map item index → flat results index for keyboard nav.
const flatIndexOf = (r: Result) => results.indexOf(r);
const isStillSearching = !!query.trim() && searchLoading && searchQuery !== query.trim();
+18 -94
View File
@@ -30,8 +30,7 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
// Settings is a global modal lazy-load so its 2.3K LOC + Stripe / OAuth helpers
// don't ship on first paint. Prefetched on idle so click-to-open feels instant.
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
import DynamicIsland from '@/app/components/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
@@ -67,13 +66,7 @@ const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigateRaw = useNavigate();
// Wrap navigation in startTransition so React treats the route swap
// as non-urgent: the click handler returns immediately and paint
// happens before the heavy unmount-old-page / mount-new-page work
// runs. Eliminates the "click → wait → page appears" gap on slow
// routes (Actions, Apps, Skills) when the main thread is busy with
// agent streaming dispatches. Same call signature as useNavigate's
// return so existing call sites stay untouched.
// startTransition wrapper: route swap becomes non-urgent so click handler returns immediately; eliminates the "click, wait, page appears" gap on slow routes.
const navigate = useMemo(() => {
const fn = (...args: Parameters<typeof navigateRaw>) => {
startTransition(() => {
@@ -111,7 +104,6 @@ const AppShell: React.FC = () => {
});
const [snackbarDismissed, setSnackbarDismissed] = useState(false);
// ---- Warning banner: no internet / no model connected ----
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
@@ -125,19 +117,11 @@ const AppShell: React.FC = () => {
};
}, []);
// Derive "any model connected" from the /agents/models response (already
// fetched into Redux at app start via Main.tsx and re-fetched by
// Settings.tsx after every subscription connect/disconnect). That endpoint
// intersects BUILTIN_MODELS with both the user's API keys AND 9Router's
// live connection state, so a non-empty byProvider means there's at least
// one usable model — regardless of whether it came from a typed API key
// or an OAuth subscription flow. This replaces the previous approach of
// polling /agents/subscriptions/status in an effect keyed to anthropicKey,
// which didn't refresh when a non-Anthropic subscription was connected.
// /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model.
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const hasModelConnected = Object.keys(modelsByProvider).length > 0;
// Don't flash the banner while the initial /agents/models fetch is in flight
// Wait for initial fetch to land before flashing the banner.
const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected);
const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
@@ -164,14 +148,7 @@ const AppShell: React.FC = () => {
(window as any).openswarm?.installUpdate();
}, [installing, dispatch]);
// Whole-dict subscriptions are deceptively expensive: `state.dashboards.items`
// and `state.outputs.items` are top-level dicts that get a NEW reference
// on any nested mutation (RTK/Immer behavior). With default referential
// equality, AppShell re-rendered on every dashboard rename, every output
// bump, every settings refresh that touched these slices, even though
// the dict CONTENTS were structurally identical from AppShell's POV.
// shallowEqual compares one level deep (key set + each value's identity),
// so AppShell now only re-renders on real structural changes.
// shallowEqual on top-level Immer dicts: nested mutations bump the dict reference, causing AppShell to re-render on every rename/output bump despite identical structure.
const dashboardItems = useAppSelector(
(state) => state.dashboards.items,
shallowEqual,
@@ -199,9 +176,7 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
// Idle-prefetch the lazy Settings chunk so click-to-open is instant.
// requestIdleCallback waits until the browser is genuinely idle so we
// don't fight first-paint work for the network slot.
// Idle-prefetch the lazy Settings chunk so click-to-open is instant; requestIdleCallback avoids fighting first-paint.
useEffect(() => {
const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
const handle = ric(() => {
@@ -286,9 +261,6 @@ const AppShell: React.FC = () => {
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
}, [sidebarWidth]);
// Native notification click handler. The notification helper fires a
// window event with the session id + dashboard id; bring the user back
// to that dashboard and queue a card focus.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail || {};
@@ -338,8 +310,6 @@ const AppShell: React.FC = () => {
? location.pathname.split('/dashboard/')[1]
: null;
// Sticky last-visited dashboard id — survives navigation away from /dashboard/:id
// so the Dashboard component can stay mounted with stable props.
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
const activeAppId = location.pathname.startsWith('/apps/')
? location.pathname.split('/apps/')[1]
@@ -397,7 +367,6 @@ const AppShell: React.FC = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
{/* Draggable title bar */}
<Box
sx={{
height: 38,
@@ -418,10 +387,7 @@ const AppShell: React.FC = () => {
<IconButton
size="small"
onClick={() => setSidebarCollapsed((prev) => !prev)}
// Onboarding handle — the runtime reads aria-expanded to
// detect a collapsed sidebar and walks the user through
// clicking this toggle before targeting any sidebar-* item,
// mirroring the customization-collapse preflight.
// Onboarding runtime reads aria-expanded to detect a collapsed sidebar.
data-onboarding="sidebar-toggle"
aria-expanded={!sidebarCollapsed}
sx={{
@@ -499,7 +465,6 @@ const AppShell: React.FC = () => {
</Box>
</Box>
{/* Warning banner: no internet or no model connected */}
<Collapse in={showWarningBanner} timeout={350} unmountOnExit>
<Box
sx={{
@@ -521,10 +486,10 @@ const AppShell: React.FC = () => {
<ErrorSlime size={22} />
<Typography sx={{ fontSize: '0.78rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
{!isOnline
? 'No internet connection agents cannot reach AI models or external services'
? 'No internet connection; agents cannot reach AI models or external services'
: (
<>
No AI model connected {' '}
No AI model connected.{' '}
<Box
component="span"
onClick={() => dispatch(openSettingsModal('models'))}
@@ -653,16 +618,11 @@ const AppShell: React.FC = () => {
}}
>
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
{/* Dashboards section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
data-onboarding="sidebar-dashboards"
// Expose expanded state so the onboarding runtime can
// skip the sidebar-click step when the section is already
// open (clicking it again would collapse it — opposite
// of what we want). Read via element.dataset.expanded /
// aria-expanded in the runtime guard.
// Onboarding reads expanded so it skips the click step (re-click would collapse).
data-expanded={dashboardsExpanded ? 'true' : 'false'}
aria-expanded={dashboardsExpanded}
sx={{
@@ -736,11 +696,7 @@ const AppShell: React.FC = () => {
return (
<Box
key={entry.id}
// Onboarding targets: every row carries a stable id so
// the AC can point at a specific dashboard, plus the
// first row gets a generic "first" alias so the AC
// can teach "click into a dashboard" without knowing
// any specific id.
// First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
data-onboarding={
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
}
@@ -815,10 +771,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Customization section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
@@ -867,12 +821,7 @@ const AppShell: React.FC = () => {
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => {
// Replaced NavLink with a manual click handler so the
// wrapped (startTransition-aware) navigate runs.
// react-router's NavLink calls its own internal
// navigate which doesn't go through our wrapper,
// bypassing the transition optimization that makes
// Actions/Skills/Modes feel instant.
// Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper.
const isActive = location.pathname === item.path;
return (
<Box
@@ -880,9 +829,7 @@ const AppShell: React.FC = () => {
data-onboarding={item.onboarding}
onClick={() => navigate(item.path)}
onMouseEnter={() => {
// Hover-prefetch the lazy chunk so the click pays
// ~0ms instead of the multi-hundred-ms chunk parse.
// See Main.tsx for the path → import map.
// Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map).
const fn = (window as any).__openswarmPrefetchRoute;
if (typeof fn === 'function') fn(item.path);
}}
@@ -895,12 +842,7 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
// 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -928,10 +870,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Apps section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
@@ -1020,12 +960,6 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -1055,7 +989,6 @@ const AppShell: React.FC = () => {
</Box>
{/* Settings */}
<Box
sx={{
px: 1,
@@ -1108,11 +1041,7 @@ const AppShell: React.FC = () => {
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
sx={{
// Hit-target is 6px for ergonomic drag but the handle is
// positioned at -3px so it overlaps the sidebar/content seam
// instead of occupying its own visible column. This kills the
// "chunky empty strip" that read as bad spacing without
// shrinking the actual drag region.
// 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
width: 6,
marginLeft: '-3px',
marginRight: '-3px',
@@ -1143,8 +1072,7 @@ const AppShell: React.FC = () => {
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page, position: 'relative' }}>
{/* Non-dashboard routes render here. Hidden when the dashboard view is active
so the persistent Dashboard layered above can take over the visible area. */}
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
<Box
sx={{
position: 'absolute',
@@ -1156,11 +1084,7 @@ const AppShell: React.FC = () => {
<Outlet />
</Box>
{/* Persistent Dashboard layer — always mounted once a dashboard has been visited.
Hidden via CSS when on other routes so webviews and dashboard state survive
route navigation. The Dashboard component reads its dashboardId from the
sticky lastDashboardId hook so its dashboardId useEffect doesn't re-fire on
incidental URL changes. */}
{/* CSS-hidden on other routes so webviews + state survive nav. */}
{lastDashboardId && (
<DashboardHost visible={isDashboardViewActive}>
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
@@ -1242,7 +1166,7 @@ const AppShell: React.FC = () => {
}}
>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded restart to update`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded; restart to update`}
</Alert>
</Snackbar>
</Box>
@@ -6,24 +6,9 @@ interface DashboardHostProps {
children: React.ReactNode;
}
/**
* Wraps the Dashboard component in a stable container that toggles visibility
* via CSS instead of unmounting. This is what keeps the embedded webviews
* alive across non-dashboard route navigation.
*
* Why this approach (vs. display: none or unmount):
* - `visibility: hidden` preserves webview state without triggering Chromium
* to mark the page as hidden (so background sub-agents keep working).
* - `display: none` would trigger full layout recalc on toggle and may pause
* pages that check `document.hidden`.
* - Unmount destroys the webview DOM element, tearing down its Chromium tab.
*
* Also provides DashboardActiveContext to all children so they can gate
* expensive work (canvas rendering, screenshot capture, etc.) on visibility.
*/
/** Stable container that hides Dashboard via CSS so embedded webviews survive non-dashboard nav. */
const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
// When transitioning from visible -> hidden, blur any focused element so
// a focused webview doesn't keep stealing keyboard input behind the scenes.
// Blur focused element on hide so a focused webview can't keep stealing keyboard input.
useEffect(() => {
if (!visible) {
const el = document.activeElement;
@@ -38,10 +23,8 @@ const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
style={{
position: 'absolute',
inset: 0,
// Negative z-index when hidden so any visible Outlet content sits above
zIndex: visible ? 10 : -1,
visibility: visible ? 'visible' : 'hidden',
// Belt-and-suspenders: even if z-index ordering glitches, no clicks land
pointerEvents: visible ? 'auto' : 'none',
}}
>
+2 -16
View File
@@ -6,21 +6,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Unified loading primitives. Three components, one aesthetic.
*
* <Skeleton variant="card|line|circle" width height />
* For full-component / full-page loads. Replaces decorative spinners.
*
* <InlineSpinner size />
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
*
* <EmptyState icon title hint />
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
*
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
*/
/** Loading primitives: Skeleton (block load), InlineSpinner (inline waits), EmptyState (no-items). */
interface SkeletonProps {
variant?: 'card' | 'line' | 'circle' | 'custom';
@@ -88,7 +74,7 @@ interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
/** Show after N ms keeps "Loading..." flash off fast paths */
/** Show after N ms; keeps "Loading..." flash off fast paths. */
delayMs?: number;
}
@@ -1,15 +1,4 @@
// 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."
// Glue between the Onboarding panel and the AC runtime; thin policy layer over acRuntime.runStep.
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -24,9 +13,7 @@ interface AttachArgs {
acRef: RefObject<AgenticCursorHandle | null>;
store: Store<RootState>;
getAccentColor: () => string;
// Resolves whether a dependency's outcome is still satisfied. If true,
// the dependency's flow is skipped during walk_again. Step-5's depCheck,
// for example, asks "is there still a live browser card on the canvas?"
/** True if a dep is still satisfied; if so walk_again skips its flow. */
isDependencySatisfied: (depId: string) => boolean;
}
@@ -84,26 +71,9 @@ class OnboardingDirector {
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.
// Abort hooks: lost-target (cached element disconnected >2.5s) and hash-route change.
const startHash = window.location.hash;
// Console-visible breadcrumb for which abort listener fired. The
// existing `report()` calls only go to analytics; we couldn't tell
// whether step 8's recurring `AbortError: aborted` was from a
// lost-target (chat-input element disconnected by an in-flight
// remount) or from a route change (`hashchange` firing as a side
// effect of e.g. ViewEditor calling history.replaceState mid-flow).
// Logging on each abort path resolves that ambiguity without
// needing to open the Network/Analytics panel.
// Console breadcrumbs distinguish lost-target vs hashchange aborts without the Analytics panel.
const onLost = (e: Event) => {
const detail = (e as CustomEvent)?.detail;
// eslint-disable-next-line no-console
@@ -151,16 +121,11 @@ class OnboardingDirector {
}
}
// Step 6 previously triggered seed-orchestration-demo here to drop a
// stub "research" agent on the canvas. We removed it — step 6 now
// reuses the real chat the user created in step 3 as the "previous
// chat" the orchestrator bosses around, so no stub is needed.
}
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.
/** Ordered roadmap (1..10); STEPS is the source of truth. */
export function getRoadmap(): OnboardingStep[] {
return STEPS;
}
@@ -1,13 +1,4 @@
// 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.
/** Docked top-right panel; states: pill, expanded, roadmap, hidden. */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -30,13 +21,9 @@ import { cursorStore } from './ac/cursorStore';
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
const PANEL_WIDTH = 420;
// 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.
/** Mirrors AgenticCursor's shape so AC visually "comes to life" out of this icon on Show me click. */
const CursorIconSmall: React.FC<{ size?: number; color: string }> = ({
size = 14,
color,
@@ -67,16 +54,11 @@ const OnboardingPanel: React.FC = () => {
const infoBtnRef = useRef<HTMLButtonElement | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
// Cursor icon inside the "Show me" button — used to calculate the AC
// spawn point so the cursor visually flies out of this exact icon.
// AC spawn point flies out of this icon.
const cursorIconRef = useRef<HTMLSpanElement | null>(null);
// Cooldown for the Show me button so rapid double-clicks don't fire
// multiple parallel step starts (each one re-triggering backend
// seed/launch calls that already have an in-flight predecessor).
// Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls.
const lastShowMeClickRef = useRef<number>(0);
// Resolve current step. Prefer explicit currentStepId; fall back to
// first uncompleted step.
const currentStep = useMemo(() => {
const explicit = progress.currentStepId
? findStepById(progress.currentStepId)
@@ -85,8 +67,7 @@ const OnboardingPanel: React.FC = () => {
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.
// Stage-relative (panel) vs overall (pill).
const stageOf = currentStep?.stage ?? 'get_started';
const stageSteps = useMemo(
() => STEPS.filter((s) => s.stage === stageOf),
@@ -99,60 +80,33 @@ const OnboardingPanel: React.FC = () => {
const total = STEPS.length;
const done = progress.completedSteps.length;
// Celebration banner — strike-through + check on the just-completed
// step. Timer lives INSIDE CelebrationView so it can't be cancelled
// by parent OnboardingPanel re-renders or AnimatePresence remounts.
// Removed the parent-level useEffect that was here; it was vulnerable
// to a "rapid re-render → cleanup → new timer → repeat" loop where
// the celebration would never actually clear.
// Timer lives inside CelebrationView so parent re-renders can't cancel it.
const justDoneStepId = progress.justCompletedStepId;
const justDoneStep = justDoneStepId ? findStepById(justDoneStepId) : null;
const handleShowMe = async () => {
if (!currentStep) return;
// Click cooldown — without this, rapid double-clicks fire startStep
// twice. Each invocation calls cancelStep() then starts fresh, but
// any in-flight async ops (seed-orchestration-demo, agent launch,
// etc) keep running because cancelStep only aborts the controller,
// not pending backend fetches. Result: multiple stub agents
// created, multiple agents launched, panel state thrashing. 600ms
// is short enough not to feel laggy, long enough to absorb the
// user's "is it broken" reflex re-click.
// 600ms cooldown: cancelStep doesn't kill in-flight backend fetches so spam would launch parallel sessions.
const now = Date.now();
if (now - lastShowMeClickRef.current < 600) return;
lastShowMeClickRef.current = now;
// If running flag is stuck at true (a prior step's runStep ended
// without resetting it — possible after an unhandled error or HMR
// cycle), forcibly cancel and reset before starting fresh. This
// unsticks the "Show me does nothing" case without forcing the
// user to reload the app.
// Unstick a stale "running" flag from a prior unhandled error or HMR; yield a tick so reset lands first.
if (progress.running) {
onboardingDirector.cancelStep();
progress.setRunning(false);
// Yield a tick so the running=false dispatch lands before we
// start the new step (otherwise the runtime's first dispatch
// races with the reset).
await new Promise<void>((r) => window.setTimeout(r, 0));
}
const iconEl = cursorIconRef.current;
const rect = iconEl?.getBoundingClientRect();
// Sanity-check the rect: if the panel is mid-transition (Framer's
// 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.
// Mid-transition rects can be 0,0,0,0; fall back to a top-right anchor.
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 });
// Watchdog: if AC fails to become visible within 2s of Show me
// (acRef.current was null after an HMR cycle, fadeIn silently
// rejected, etc), the panel stays hidden because nothing resets
// `running`. Check the cursorStore — if visible is still false,
// recover so the panel comes back instead of stranding the user.
// 2s watchdog recovers the panel if AC never becomes visible (HMR / silent rejection).
const watchedStepId = currentStep.id;
window.setTimeout(() => {
const acVisible = cursorStore.get().visible;
@@ -168,12 +122,7 @@ const OnboardingPanel: React.FC = () => {
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.
// Slide panel off-screen while AC runs so it doesn't sit on top of top-right targets (Skills install, "+ New app", etc).
const panelHidden = progress.running;
return (
@@ -187,11 +136,7 @@ const OnboardingPanel: React.FC = () => {
transition={{ type: 'spring', stiffness: 280, damping: 32 }}
sx={{
position: 'fixed',
// 38px title bar (drag region with traffic lights / OpenSwarm logo)
// + 6px breathing room. Sits just below the title bar — clear of
// the logo in the right corner but tighter to it than the
// previous 54px so the pill doesn't visually float away from
// the chrome.
// 38px title bar + 6px breathing room.
top: 44,
right: 16,
zIndex: 1200,
@@ -277,10 +222,6 @@ const OnboardingPanel: React.FC = () => {
overflow: 'hidden',
}}
>
{/* Header — stage label + minimize + progress bar. No
bottom border anymore: the progress bar IS the
visual divider between header and body, no need for
a second separator line below it. */}
<Box
sx={{
px: 1.6,
@@ -347,8 +288,6 @@ const OnboardingPanel: React.FC = () => {
</Box>
</Box>
{/* Body — celebration overlay or current step. AnimatePresence
crossfades between them so step transitions feel smooth. */}
<Box sx={{ position: 'relative' }}>
<AnimatePresence mode="wait" initial={false}>
{justDoneStep ? (
@@ -407,9 +346,7 @@ const OnboardingPanel: React.FC = () => {
</AnimatePresence>
</Box>
{/* Floating "?" info popover, anchored to the info icon. Renders
OUTSIDE the panel container so it can extend to the left without
clipping. */}
{/* Rendered outside the panel container so it can extend left without clipping. */}
{infoOpen && currentStep && (
<InfoPopover
stepId={currentStep.id}
@@ -445,10 +382,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
onToggleInfo,
running,
}) => {
// Click-to-zoom on the demo video. Lives at the card level so the
// overlay is portaled out (full viewport) regardless of how the panel
// is positioned. Auto-collapses on step change so a leftover overlay
// from step N doesn't linger into step N+1.
// Auto-collapses on step change so a leftover overlay from step N doesn't linger into step N+1.
const [videoExpanded, setVideoExpanded] = useState(false);
useEffect(() => {
setVideoExpanded(false);
@@ -516,10 +450,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
width: '100%',
height: '100%',
objectFit: 'cover',
// The source recordings have baked-in black side bars
// (recorded at a wider canvas than the OpenSwarm window
// actually filled). Scaling up + overflow:hidden on the
// parent crops them off the visible thumbnail area.
// Source recordings have baked-in black side bars; scale + parent overflow:hidden crops them off.
transform: 'scale(1.0)',
transformOrigin: 'center',
pointerEvents: 'none',
@@ -572,9 +503,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
<ButtonBase
onClick={onOpenRoadmap}
sx={{
// mlAuto: pushes the help icon (next sibling) to the far
// right while keeping "See all todos" tucked next to Show
// me. Visual rhythm: [Show me] See all todos ............ ?
fontSize: 12.5,
fontWeight: 500,
color: c.text.secondary,
@@ -599,11 +527,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
</IconButton>
</Box>
</Box>
{/* Click-zoom overlay — portaled to body so it covers the full
viewport regardless of how the panel is positioned. Lives as a
sibling of the main card Box rather than as a child so the card
Box's children list stays a clean array of static elements
(helps React's children-validation in dev). */}
{videoExpanded && step.videoSrc
? createPortal(
<Box
@@ -681,18 +604,12 @@ interface CelebrationProps {
const CelebrationView: React.FC<CelebrationProps> = ({ step, accent }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Self-clearing timer: lives with the component instance and
// dispatches clearJustCompleted on mount. Because this component
// ONLY mounts when justCompletedStepId is set and unmounts when
// it's cleared, the timer fires exactly once per celebration.
// Cannot be cancelled by parent re-renders.
// Self-clearing timer fires once per celebration; cannot be cancelled by parent re-renders.
useEffect(() => {
const t = window.setTimeout(() => {
dispatch(clearJustCompleted());
}, CELEBRATION_MS);
return () => window.clearTimeout(t);
// Empty deps = fires once on mount, cleans up on unmount. The
// dispatch ref is stable per redux store.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -804,8 +721,6 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
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 });
@@ -815,12 +730,10 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
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();
@@ -1,7 +1,4 @@
// 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.
// Mirrors persisted onboarding-v2 state; OnboardingRoot debounce-writes to localStorage on change.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -13,8 +10,7 @@ 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).
/** Multi-choice answers per opId; drives branching and analytics. */
multiChoiceAnswers?: Record<string, string>;
}
@@ -26,25 +22,13 @@ export interface OnboardingProgressState {
panelMode: PanelMode;
dismissedAt: number | null;
perStepState: Record<string, PerStepState>;
// Runtime-only — not persisted. True while AC is actively executing a
// step's ops. The panel hides chrome and the user can't open the roadmap
// mid-flow without first cancelling.
/** Runtime-only; true while AC is executing a step's ops. */
running: boolean;
// Set on first launch detection so we don't re-init from defaults on
// every mount.
/** Set on first-launch detection so we don't re-init 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).
/** Brief celebration marker; clearJustCompleted clears it ~1.5s after the animation. */
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.
/** True after explicit restart-from-Settings; suppresses skipIf so the tour feels fresh. */
disableSkipIf: boolean;
}
@@ -86,9 +70,7 @@ const initialState: OnboardingProgressState = {
startedAt: 0,
completedSteps: [],
currentStepId: null,
// Default to expanded users land on the dashboard with the full
// step card visible so they see the next milestone + video preview
// without having to click into the pill first.
// Default expanded so users see next milestone + video preview on dashboard land.
panelMode: 'expanded',
dismissedAt: null,
perStepState: {},
@@ -123,7 +105,6 @@ const slice = createSlice({
state.disableSkipIf = Boolean(action.payload.disableSkipIf);
},
hydrate(state, action: PayloadAction<OnboardingProgressState>) {
// Replace from localStorage on launch.
Object.assign(state, action.payload, { running: false, initialized: true });
},
setPanelMode(state, action: PayloadAction<PanelMode>) {
@@ -145,8 +126,7 @@ const slice = createSlice({
markStepCompleted(state, action: PayloadAction<string>) {
if (!state.completedSteps.includes(action.payload)) {
state.completedSteps.push(action.payload);
// Trigger the celebration / strike-through animation. The panel
// listens for this and clears it ~1.5s later via clearJustCompleted.
// Triggers celebration anim; panel clears via clearJustCompleted after ~1.5s.
state.justCompletedStepId = action.payload;
}
},
@@ -176,11 +156,7 @@ const slice = createSlice({
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.
// Explicit restart: suppress skipIf so residual prior-tour data can't auto-mark.
state.disableSkipIf = true;
},
},
@@ -1,5 +1,4 @@
// 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.
/** 10-step roadmap modal opened from the panel's "See all todos"; Stage 2 unlocks once Stage 1 is fully complete. */
import React from 'react';
import { Modal, Box, Typography, IconButton, Button } from '@mui/material';
@@ -37,28 +36,18 @@ const OnboardingRoadmapModal: React.FC = () => {
progress.setPanelMode('expanded');
};
// Anchor the roadmap to the same top-right corner the panel sits in,
// so visually it reads as the panel "expanding into" the full roadmap
// rather than a centered modal that breaks spatial continuity. The
// origin point matches OnboardingPanel's top:44 / right:16 dock.
// Anchored top:44 / right:16 to match OnboardingPanel's dock so the modal reads as the panel expanding.
return (
<Modal
open={open}
onClose={close}
// Disable Modal's internal flex centering — we position the inner
// box absolutely from the top-right corner ourselves.
sx={{ inset: 0 }}
slotProps={{
backdrop: {
sx: { backgroundColor: 'rgba(0,0,0,0.42)' },
},
}}
// Modal mounts as soon as `open` is true; AnimatePresence inside
// owns the actual exit animation, so we keep keepMounted off and
// use AnimatePresence with mode="wait".
>
{/* Outer Box gets focus / aria attributes from MUI Modal. The
motion.div inside handles the slide-in. */}
<Box
sx={{
position: 'absolute',
@@ -79,8 +68,6 @@ const OnboardingRoadmapModal: React.FC = () => {
>
<Box
sx={{
// Roughly the same width as the expanded panel, just a touch
// wider so the 8-row roadmap breathes. 360 vs panel's 320.
width: 360,
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
@@ -93,7 +80,6 @@ const OnboardingRoadmapModal: React.FC = () => {
fontFamily: c.font.sans,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
@@ -129,7 +115,6 @@ const OnboardingRoadmapModal: React.FC = () => {
</IconButton>
</Box>
{/* Stages */}
<Box sx={{ px: 2.4, pt: 1.6, pb: 0.5 }}>
{STAGE_GROUPS.map((group, gi) => {
const stageDone = group.steps.filter((s) =>
@@ -191,9 +176,7 @@ const OnboardingRoadmapModal: React.FC = () => {
key={step.id}
onClick={() => {
if (isLocked) return;
// If a step is mid-flow, abort it before
// jumping. Otherwise the AC keeps animating
// for a step the user no longer sees.
// Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees.
if (progress.running) {
onboardingDirector.cancelStep();
}
@@ -270,7 +253,6 @@ const OnboardingRoadmapModal: React.FC = () => {
})}
</Box>
{/* Footer */}
<Box
sx={{
px: 2.4,
@@ -1,5 +1,4 @@
// Top-level mount for the onboarding-v2 system. Hydrates persisted state,
// attaches the Director, mounts the Panel + AC.
// Top-level mount for onboarding-v2: hydrate state, attach Director, mount Panel + AC.
import React, { useEffect, useRef } from 'react';
import { useStore } from 'react-redux';
@@ -32,7 +31,6 @@ const OnboardingRoot: React.FC = () => {
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;
@@ -43,15 +41,7 @@ const OnboardingRoot: React.FC = () => {
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.
// Start with no pre-completed steps; live subscriber handles skipIf after baseline capture.
dispatch(
init({
currentStepId: STEPS[0]?.id ?? null,
@@ -61,19 +51,7 @@ const OnboardingRoot: React.FC = () => {
);
}, [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.
// Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches.
useEffect(() => {
let last = new Set(progress.completedSteps);
let lastBrowserCount = Object.keys(
@@ -86,20 +64,7 @@ const OnboardingRoot: React.FC = () => {
(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.
// Snapshot pre-satisfied skipIf predicates after a 2s settle; those steps need real user action to mark.
let baselinePredicateMet: Set<string> | null = null;
const baselineCaptureAt = Date.now() + 2000;
let lastStatuses: Record<string, string> = {};
@@ -115,11 +80,7 @@ const OnboardingRoot: React.FC = () => {
seedStatuses();
let pending = false;
// Cached slice references — if these are referentially equal to what
// we saw last microtask, NOTHING we care about could have changed.
// Redux Toolkit's Immer produces new references only on slice writes,
// so identity comparison is sound and ~free. Drops the steady-state
// cost of this subscriber to a 5-pointer comparison per microtask.
// Slice-ref identity check; Immer mutates only on write so this 5-pointer compare is sound and free.
let prevAgents: unknown = null;
let prevDashboardLayout: unknown = null;
let prevOutputs: unknown = null;
@@ -129,11 +90,7 @@ const OnboardingRoot: React.FC = () => {
const runCheck = () => {
pending = false;
const state = store.getState();
// Reference-equality early-out. If none of the slices that drive
// any predicate, count, or status walk have changed reference,
// there's no work to do. Streaming chunks, agent message updates,
// settings polls all dispatch but most of them touch a single
// unrelated slice — so this skips ~95% of microtask wakeups.
// Early-out if no relevant slice reference moved; skips ~95% of microtask wakeups.
const sAgents = (state as any).agents;
const sLayout = state.dashboardLayout;
const sOutputs = (state as any).outputs;
@@ -153,8 +110,7 @@ const OnboardingRoot: React.FC = () => {
if (!anyChanged) return;
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.
// Capture pre-satisfied predicates after the fetch settle; sticky for the run.
if (baselinePredicateMet === null && Date.now() >= baselineCaptureAt) {
baselinePredicateMet = new Set();
for (const s of STEPS) {
@@ -165,11 +121,7 @@ const OnboardingRoot: React.FC = () => {
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.
// Skip evaluation if suppressed, pre-baseline, or every skippable is already marked.
if (
!suppressSkipIf &&
!allSkippablesDone &&
@@ -178,11 +130,7 @@ const OnboardingRoot: React.FC = () => {
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.
// Baseline-met predicates require real user action (bus events or outro) to mark.
if (baselinePredicateMet.has(s.id)) continue;
if (s.skipIf(state)) {
last = new Set([...Array.from(last), s.id]);
@@ -228,18 +176,14 @@ const OnboardingRoot: React.FC = () => {
};
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.
// Coalesce N dispatches in the same microtask into 1 check.
if (pending) return;
pending = true;
queueMicrotask(runCheck);
});
}, [progress.completedSteps, dispatch, store]);
// Persist Redux progress localStorage, debounced.
// Persist Redux progress to localStorage, debounced.
useEffect(() => {
if (!progress.initialized) return;
const t = window.setTimeout(() => {
@@ -248,14 +192,13 @@ const OnboardingRoot: React.FC = () => {
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."
// Step 4: browser card currently on canvas.
if (depId === 'use_browser') {
const cards = store.getState().dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
@@ -266,9 +209,7 @@ const OnboardingRoot: React.FC = () => {
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.
// Wait for sign-in state so we don't render under the SignInGate's z-index.
if (!settingsLoaded || !userId) return null;
if (!progress.initialized) return null;
@@ -1,6 +1,4 @@
// 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).
// Transient visual gesture helpers: click ripple, drag-rect, glow.
export function clickRipple(x: number, y: number, color: string): void {
const SIZE = 28;
@@ -46,7 +44,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
'width: 0px',
'height: 0px',
`border: 1.5px dashed ${color}`,
`background: ${color}1a`, // ~10% alpha
`background: ${color}1a`,
'pointer-events: none',
'z-index: 10499',
'border-radius: 4px',
@@ -69,9 +67,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
});
}
// 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.
/** Soft glow rect over a target (no click); caller must invoke the returned cleanup. */
export function spawnGlowRect(target: HTMLElement, color: string): () => void {
const rect = target.getBoundingClientRect();
const pad = 6;
@@ -100,7 +96,7 @@ export function spawnGlowRect(target: HTMLElement, color: string): () => void {
};
}
// Wait helper used between ops. Avoids `setTimeout` everywhere.
/** Promise-wrapped setTimeout for use between ops. */
export function sleep(ms: number): Promise<void> {
return new Promise((r) => window.setTimeout(r, ms));
}
@@ -11,43 +11,16 @@ interface Props {
}
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;
// Distance from the bubble edge to the rounded corner radius — the
// tail's anchor x is clamped between TAIL_PAD and (w - TAIL_PAD) so
// the tail never juts past the corner.
const TAIL_PAD = 16;
// Pokémon-dialog cadence — letters pop in steadily, punctuation gets
// a small extra pause so sentences "land" instead of slurring together.
// Slowed 50% (was 20ms/char) so the popup reads at a more deliberate
// pace, matching the AC cursor's calmer motion.
const STREAM_MS_PER_CHAR = 30;
const STREAM_PUNCT_EXTRA_MS = 210; // after . , ! ? ; : (also +50%)
/** Extra pause after . , ! ? ; : */
const STREAM_PUNCT_EXTRA_MS = 210;
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: vertical-only — the bubble sits DIRECTLY ABOVE the
* cursor (centered horizontally on the cursor's actual x), with the
* tail pointing down at the target icon. Flips to BELOW the cursor
* only when there isn't room above. This places the popup "over" the
* thing it's referring to instead of beside it, so adjacent siblings
* (toolbar [+ grid globe history note], chat-input [cursor-circle clip
* mic], etc.) are never covered by the bubble's body.
*
* The tail anchors at the cursor's actual x relative to the bubble's
* (possibly clamped) left edge, so it still points at the icon even
* when the bubble is shifted by the viewport-edge clamp.
*/
/** Non-blocking cursor popup; streams char-by-char above the cursor (flips below if no room). */
const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
@@ -64,19 +37,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY: true,
});
// 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."
//
// Diagnostic popups (anything containing the literal `[debug]`
// marker) skip streaming entirely. The recovery popup that fires on
// step failure carries a `[debug] <error message>` suffix so the
// user can see WHY a step bailed without opening DevTools — but at
// 30 ms/char + 210 ms per punctuation, the suffix takes the full
// 14 s popup duration to even start rendering, so by the time the
// user reads it the popup is already gone. Instant-render for these
// means the diagnostic appears immediately.
// [debug] popups skip streaming so the diagnostic suffix is visible immediately.
const isDebugPopup = text.includes('[debug]');
const skipStream = isDebugPopup || text.length < STREAM_MIN_CHARS;
const [streamCount, setStreamCount] = useState<number>(
@@ -97,9 +58,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
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.
// Punctuation we just revealed gets an extra beat.
const justShown = text[i - 1];
const isPunct = /[.,!?;:]/.test(justShown);
const delay = STREAM_MS_PER_CHAR + (isPunct ? STREAM_PUNCT_EXTRA_MS : 0);
@@ -118,8 +77,6 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const vw = window.innerWidth;
const vh = window.innerHeight;
// Default: bubble centered on cursor's x, sitting above the cursor.
// Flip below only when there isn't room above.
let nx = x - w / 2;
let ny = y - h - offset.y;
let flipY = true;
@@ -128,10 +85,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY = false;
}
// Horizontal clamp — keep the bubble on-screen. The tail's anchor x
// is computed AFTER clamping so the tail always points at the
// cursor's actual position even when the bubble has been shoved
// inward by the viewport edge.
// Tail anchor x is computed AFTER clamp so it still points at the cursor when bubble shifts.
const nxClamped = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
const nyClamped = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
const tailRaw = x - nxClamped;
@@ -143,8 +97,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
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.
// Reserve full width with invisible chars so the bubble doesn't jiggle as letters arrive.
const isStreaming = streamCount < text.length;
return (
@@ -160,9 +113,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
}}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
// Slowed 50% from {0.14, stiffness 320, damping 32} — gives the
// bubble a more deliberate arrival, in sync with the cursor's
// gentler spring.
// Slowed 50% from {0.14, 320, 32}; matches cursor spring.
opacity: { duration: 0.21 },
scale: { duration: 0.21 },
x: { type: 'spring', stiffness: 160, damping: 22 },
@@ -191,10 +142,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
fontFamily: c.font.sans,
}}
>
{/* Tail pointing back at the cursor. Centered on the cursor's
actual x (via tailLeft) so the diamond's point lands on the
target icon, regardless of whether the bubble itself was
shifted by the viewport clamp. */}
{/* Tail anchored on cursor's actual x via tailLeft; lands on target despite bubble clamp. */}
<Box
sx={{
position: 'absolute',
@@ -206,11 +154,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
top: pos.flipY ? 'auto' : -5,
bottom: pos.flipY ? -5 : 'auto',
left: pos.tailLeft - 5,
// flipY=true bubble is above cursor, tail at bubble's
// bottom edge → bottom-right corner borders visible so the
// diamond points down at the cursor.
// flipY=false → bubble is below cursor, tail at top edge →
// top-left corner borders visible, diamond points up.
// flipY true: bubble above, tail at bottom (br corners visible, points down). flipY false flips.
borderRight: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderBottom: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderTop: pos.flipY ? 'none' : `1px solid ${c.accent.primary}`,
@@ -219,9 +163,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
/>
<Typography
sx={{
// Sized to feel like a Pokémon dialog — small but firm.
// 0.85rem reads cleanly without dominating the screen,
// and pairs with the bolder weight to stay legible.
// 0.85rem with bold weight reads cleanly without dominating.
fontSize: '0.85rem',
color: c.text.primary,
fontWeight: 600,
@@ -1,18 +1,6 @@
// Type a string into a target input or contentEditable element one character
// at a time, dispatching events that React's reconciler observes so the
// product's controlled input state stays in sync.
//
// React intercepts native value setters on <input>/<textarea> via a
// prototype-level descriptor, then dispatches 'input' events to its own
// synthetic event system. To make a fake change visible to React, we
// have to invoke the native setter via the prototype descriptor and then
// dispatch a real 'input' event. Setting `el.value = ...` directly is
// silently ignored by React's onChange.
// Type into input/textarea/contentEditable using React-prototype native setters so onChange fires.
// Version marker so we can verify the dev bundle actually reloaded after
// editing this file. Check `window.__OPENSWARM_TYPEINTO__` in DevTools
// — if it's missing or shows an older tag, Electron's renderer is
// running a cached bundle and needs a Cmd+R hard-reload.
// Bundle-version marker; check window.__OPENSWARM_TYPEINTO__ to confirm dev-reload landed.
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_TYPEINTO__ = 'v2-dom-direct-2026-05-12';
}
@@ -50,32 +38,10 @@ function dispatchInput(el: HTMLElement): void {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// contentEditable fields (the agent chat input is one) need a different
// path than <input>/<textarea>. Setting textContent nukes rich-content
// children (skill pills, etc), so we append a Text node at the end and
// dispatch a real InputEvent that React's reconciler treats as a
// keystroke. We used to call document.execCommand('insertText') here
// instead — that's the "idiomatic" way to programmatically type into a
// contentEditable — but in Electron with a webview loaded in the
// preview pane (App Builder step 8 / step 5 / step 6 all hit this),
// the webview steals document focus during its load. execCommand
// requires the host document to be focused AND the active element to
// be editable; without focus it silently no-ops while still returning
// true, so the wizard's `typeInto` "succeeded" but no characters ever
// landed, hasContent stayed false on the chat input, the send button
// never rendered, and step 8's `move_to chatSendButton` then burned
// its 15 s waitForSelector and threw into the recovery popup. The
// AC's "cursor" is purely visual — it never fires real focus events
// — so there's no way to get document focus back without the user
// clicking. DOM-level insertion + dispatched InputEvent works
// regardless of focus state.
// contentEditable: append a Text node + dispatch InputEvent; execCommand silently no-ops when a webview steals focus.
function insertContentEditableText(el: HTMLElement, ch: string): void {
el.focus();
// Append at the very end of the editable. Walk to the deepest
// last-text-node so we don't insert into the middle of a skill pill
// wrapper (those are inline-block element children with their own
// text). If the last child is an element (e.g., a <span> skill
// pill), we append a sibling text node after it.
// Append at the very end; walk past skill-pill spans by appending a sibling text node.
const range = document.createRange();
const last = el.lastChild;
if (last && last.nodeType === Node.TEXT_NODE) {
@@ -95,10 +61,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
sel.removeAllRanges();
sel.addRange(range);
}
// React's controlled-input bridge listens for `input` events. The
// `inputType: insertText` + `data: ch` mirrors what a real keystroke
// produces, so handleInput → updateHasContent fires and hasContent
// flips true → the send button finally renders.
// inputType:insertText + data:ch mirrors a real keystroke so React's handleInput fires.
el.dispatchEvent(
new InputEvent('input', {
bubbles: true,
@@ -111,8 +74,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
export interface TypeIntoOptions {
speedMs?: number;
// Optional callback fired after each character — lets the cursor
// re-align to the input's right edge as text grows.
/** Per-char callback so the cursor can re-align to the input's right edge as text grows. */
onTick?: () => void;
}
@@ -129,17 +91,11 @@ export async function typeInto(
text: string,
opts: TypeIntoOptions = {},
): Promise<void> {
// Default char-cadence — faster than the original 40ms (which felt
// like watching molasses for long URLs). 18ms is still slow enough to
// read live but doesn't make typing the main bottleneck of the step.
// 18ms default; readable without making typing the bottleneck.
const speed = opts.speedMs ?? 18;
el.focus();
// Per-character cadence is constant (no jitter — variable timing reads
// as glitchy, not natural). The one exception: insert a natural-reading
// pause after a comma / sentence-terminator / colon / semicolon so the
// streamed text breathes the way a human would. Anything else types at
// the constant `speed` value, beat by beat.
// Constant cadence (jitter reads glitchy); only punctuation gets a longer pause to breathe.
const punctPause = (ch: string): number => {
if (ch === ',') return 220;
if (ch === '.' || ch === '!' || ch === '?') return 320;
@@ -147,9 +103,6 @@ export async function typeInto(
return 0;
};
// Branch on element kind. contentEditable (the agent ChatInput uses
// a contentEditable div for skill-pill support) requires execCommand;
// <input>/<textarea> require the React-prototype-setter dance.
if (el.isContentEditable) {
for (const ch of text) {
insertContentEditableText(el, ch);
@@ -170,16 +123,7 @@ export async function typeInto(
}
}
// Post-type verification. Under heavy main-thread load (many agents
// streaming concurrently), execCommand('insertText') can silently
// no-op while React's reconciler is starved — AC "types" but the
// characters never land in the controlled input. Without this check,
// step 8 (App Builder) would "complete" with an empty draft and the
// user would see no app get built.
//
// After typing, give React up to 500ms to commit, then re-read the
// effective text. If it's missing most of what we typed, fall back
// to a single-shot insert that's much more reliable under load.
// Verify post-typing under load: if React's reconciler dropped chars, fall back to single-shot insert.
const target = text.trim();
if (!target) return;
for (let i = 0; i < 5; i++) {
@@ -188,8 +132,7 @@ export async function typeInto(
if (got.length >= Math.floor(target.length * 0.8)) return;
}
// Fallback: nuke contents and insert the full string in one shot.
// Loses the typing animation but preserves the user-visible outcome.
// Fallback: nuke contents and insert in one shot; loses animation, preserves outcome.
try {
if (el.isContentEditable) {
el.focus();
@@ -229,6 +172,6 @@ export async function typeInto(
dispatchInput(el);
}
} catch {
/* best-effort runtime's wait_user will time out and recover */
/* best-effort; runtime's wait_user will time out and recover */
}
}
@@ -29,38 +29,12 @@ export interface AgenticCursorHandle {
transition?: Record<string, unknown>,
) => Promise<void>;
pressClick: () => Promise<void>;
/**
* Lock the cursor to a live data-onboarding selector. After this is
* called the cursor re-resolves the selector and re-reads its rect on
* every animation frame, pinning itself (and any attached popup) to
* the element's current center. Survives reflows, scrolls, sidebar
* collapses, and React node swaps (uninstalled-card → installed-card,
* etc.) — the cursor follows the live target instead of stranding
* itself at the rect we read at the time of move_to.
*
* Pass an offset to override the default (center-of-rect). Calling
* startTracking again replaces any prior tracker; the next op that
* physically moves the cursor (move_to / click / type_into /
* drag_select / outro) calls stopTracking automatically.
*/
/** Pin cursor to a live selector; rAF re-resolves so it follows reflows + React node swaps. */
startTracking: (selector: string, offset?: { x: number; y: number }) => void;
stopTracking: () => void;
/**
* Show a non-blocking popup above the cursor. Returns immediately;
* the popup stays visible until hidePopup() is called or another
* showPopup replaces it. The runtime calls hidePopup() before any op
* that physically moves the cursor or types, so the popup naturally
* disappears when the cursor's "instruction" no longer applies.
*
* Placement is fixed: bubble centered on the cursor's x, sitting
* directly above the cursor (auto-flips below if no room above).
* See ACPopup for the full positioning logic.
*/
/** Non-blocking popup above cursor; auto-clears on next physical-move op. */
showPopup: (text: string) => void;
/**
* Single-select multi-choice. Resolves with the chosen option id; the
* panel that calls this can route the rest of the flow accordingly.
*/
/** Single-select multi-choice; resolves with the chosen option id. */
showMultiChoice: (q: string, opts: ACMultiChoiceOption[]) => Promise<string>;
hidePopup: () => void;
getPosition: () => { x: number; y: number };
@@ -76,11 +50,7 @@ interface MultiChoiceState {
resolve: (id: string) => void;
}
// Snappy spring — back to the tight 260/26 from before the 50%
// slowdown. The "calm" feel of the AC now comes from the popup's
// slower typewriter cadence + the 3s dwell floor; the cursor itself
// stays responsive so bubble-less moves (move_to → click, move_to →
// type_into, the canvas-controls tour) don't feel sluggish.
// Snappy 260/26 spring; calm comes from popup cadence + 3s dwell, not cursor delay.
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
@@ -91,19 +61,14 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const [popup, setPopup] = useState<PopupState | null>(null);
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
// Active sticky-tracker handle. Set by startTracking, cleared by
// stopTracking. Survives renders via ref so the rAF loop can be
// cancelled cleanly even if the component re-renders mid-flight.
const trackerRef = useRef<{ stop: () => void } | null>(null);
// Mirror the cursor's logical position into the cursorStore so popups
// can follow without re-running through Framer's animation pipeline.
// Mirrored into cursorStore so popups follow without re-running through Framer's animation pipeline.
const writePos = (x: number, y: number, vis = true) => {
posRef.current = { x, y };
cursorStore.set({ x, y, visible: vis });
};
// Stop any sticky tracker. Idempotent.
const stopTrackingInternal = () => {
if (trackerRef.current) {
trackerRef.current.stop();
@@ -111,10 +76,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}
};
// Defensive: if the AC unmounts mid-flow (Director.detach, panel
// hidden), the rAF callback would otherwise keep firing and pinning a
// dead component's `controls` to the live target every frame. The
// unmount cleanup cancels it.
// Unmount cleanup: without this the rAF callback keeps pinning a dead component's `controls` every frame after Director.detach.
useEffect(() => {
return () => stopTrackingInternal();
}, []);
@@ -132,10 +94,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
});
},
async moveTo(x, y, transition) {
// moveTo is for animated jumps to a fixed coord. Stop any prior
// tracker first so it doesn't keep snapping the cursor back to its
// old anchor mid-animation. The runtime calls startTracking after
// the await resolves, re-pinning to the live target.
// Stop prior tracker so it doesn't snap the cursor back to its old anchor mid-animation.
stopTrackingInternal();
await controls.start({
x,
@@ -166,39 +125,19 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const offY = offset?.y ?? 0;
let cancelled = false;
let rafId = 0;
// Cache the resolved node by reference. Re-querying every frame
// would make the cursor flicker between transient duplicate matches
// when React re-renders (e.g. Reddit Card hover state, Switch
// animation, install-toggle transition). Holding the node stable
// means the cursor follows the SAME element through reflows; we
// only re-query when the cached node leaves the document.
// Cache node by reference; re-querying every frame flickers between transient duplicate matches during React re-renders.
let cachedEl: HTMLElement | null = resolveSelector(selector);
let lastX = posRef.current.x;
let lastY = posRef.current.y;
// Lost-target tracking. If the cached element disconnects (user
// navigates away, collapses the section, etc) and we can't re-find
// it for >LOST_TIMEOUT_MS, fire the lost-target event so the
// runtime can outro gracefully and offer a recovery hint.
let lostSinceMs: number | null = null;
const LOST_TIMEOUT_MS = 2500;
const EPSILON = 0.5;
// Drop frames where the resolved rect would teleport the cursor by
// more than this. Real reflows move elements a few px per frame;
// 600px instantly is a sign of a stale/transient rect mid-commit.
// 600px+ rect jump in one frame = stale/transient mid-commit, not a real reflow.
const MAX_JUMP_PX = 600;
// Title-bar drag region (38px in AppShell). Pinning the cursor
// there lands it on the macOS traffic lights / Electron drag-area
// — never an intentional onboarding target. Skip those frames.
const TITLE_BAR_BOTTOM = 38;
// Throttle the rAF tracker to ~30fps. The browser fires rAF at the
// monitor refresh (60-144Hz typically), and re-querying rects +
// applying transforms every single frame is wasted work for what
// is fundamentally a "follow this rect" loop. 30fps still feels
// glued because the visible jitter threshold for static UI is
// higher than for animated UI. Halves rAF callback cost during
// pinned ops.
// ~30fps; per-frame rect reads are wasted for "follow this rect."
let lastTickAt = 0;
const TICK_INTERVAL_MS = 33; // ~30fps
const TICK_INTERVAL_MS = 33;
const tick = () => {
if (cancelled) return;
const now = performance.now();
@@ -211,17 +150,11 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (!cachedEl || !cachedEl.isConnected) {
cachedEl = resolveSelector(selector);
if (!cachedEl) {
// Element vanished. Start (or continue) the lost-target
// countdown — once we exceed the timeout, signal the
// runtime to abort.
const now = Date.now();
if (lostSinceMs === null) lostSinceMs = now;
if (now - lostSinceMs > LOST_TIMEOUT_MS) {
cancelled = true;
cancelAnimationFrame(rafId);
// Custom event the runtime listens for. Decoupled from
// controls/Promise machinery so we can fire from inside
// a rAF tick without races.
window.dispatchEvent(
new CustomEvent('openswarm:onboarding:lost_target', {
detail: { selector },
@@ -230,7 +163,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return;
}
} else {
// Re-acquired — clear the countdown.
lostSinceMs = null;
}
} else {
@@ -242,11 +174,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (r.width > 0 || r.height > 0) {
const cx = r.left + r.width / 2 + offX;
const cy = r.top + r.height / 2 + offY;
// Viewport guards: skip frames where pinning would land the
// cursor outside the visible window OR inside the title-bar
// drag region. These don't help the user — they're symptoms
// of a stale read or a hidden/overflowed target — and the
// next legitimate frame will pin correctly.
// Off-window / title-bar frames are stale-reads or hidden targets.
const offWindow =
cx < 0 ||
cy < 0 ||
@@ -280,9 +208,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
stopTrackingInternal();
},
showPopup(text) {
// Non-blocking — replaces any existing popup. Caller advances the
// flow; popup auto-clears on the next op that physically moves the
// cursor (move_to / click / type_into / drag_select / outro).
setPopup({ text });
},
showMultiChoice(question, options) {
@@ -300,9 +225,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
hidePopup() {
setPopup(null);
if (multiChoice) {
// Defensive — multi_choice is supposed to resolve via user pick,
// but if the runtime aborts mid-question we don't want a dangling
// promise. Resolve with '' so callers can detect dismissal.
// Resolve with '' on abort so the promise doesn't dangle.
multiChoice.resolve('');
setMultiChoice(null);
}
@@ -316,15 +239,13 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return createPortal(
<>
{/* Cursor body — animated by Framer Motion. pointer-events:none so it
never blocks user interaction with the underlying app. */}
{/* pointer-events:none so the cursor never blocks underlying app interaction. */}
<motion.div
animate={controls}
onUpdate={(latest) => {
const x = typeof latest.x === 'number' ? latest.x : posRef.current.x;
const y = typeof latest.y === 'number' ? latest.y : posRef.current.y;
// Avoid React re-renders on every frame; just push to the external
// store so popups (which subscribe via useSyncExternalStore) follow.
// Push to external store instead of re-rendering; popups subscribe via useSyncExternalStore.
if (visible) cursorStore.set({ x, y });
}}
style={{
@@ -333,19 +254,12 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
left: 0,
zIndex: 10500,
pointerEvents: 'none',
// Translate origin: top-left of viewport. The animated x/y is the
// cursor tip's logical position.
transformOrigin: 'top left',
// Visual offset so the arrow's "tip" sits at (x,y) — the SVG below
// is drawn from its top-left, so shift it slightly up-and-left to
// align the pointer.
}}
>
{visible && (
<motion.div
animate={{
// Subtle idle pulse — closer to a soft heartbeat than a
// bouncing scale. Stays out of the way visually.
scale: [1, 1.04, 1],
}}
transition={{
@@ -355,9 +269,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}}
style={{
transform: 'translate(-2px, -2px)',
// Two-layer glow: tight inner ring + softer outer halo.
// Tuned so the cursor reads clearly against light AND dark
// canvases without being distracting.
// Tight inner ring + soft outer halo reads on light AND dark canvases.
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
@@ -366,9 +278,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
)}
</motion.div>
{/* Popups portaled separately so their pointer-events:auto isn't
inherited from the cursor wrapper's pointer-events:none. They
subscribe to cursorStore to track the live position. */}
{/* Portaled separately so cursor wrapper's pointer-events:none doesn't propagate. */}
<AnimatePresence>
{popup && <ACPopup key="popup" text={popup.text} />}
{multiChoice && (
@@ -388,7 +298,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
AgenticCursor.displayName = 'AgenticCursor';
export default AgenticCursor;
// Standard arrow cursor shape — 22x22, drawn pointing down-right.
/** 22x22 arrow cursor, points down-right. */
const CursorArrow: React.FC<{ color: string }> = ({ color }) => (
<svg
width="22"
@@ -1,11 +1,4 @@
// AC runtime — executes a step's ACOp[] sequence by calling into the
// AgenticCursor handle and the gesture/typing helpers. Runs ops sequentially
// with `await`; aborts cleanly when the AbortSignal fires (user dismisses
// panel mid-step, opens a different step, etc).
//
// Pure async. Not a class. Director (in OnboardingDirector.ts) is the
// caller — it owns the lifecycle (AbortController, AC ref, accent color
// resolution from the theme).
/** AC runtime: sequentially awaits a step's ACOp[] via the AgenticCursor handle; aborts on AbortSignal. */
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -18,7 +11,6 @@ import {
} from '../OnboardingProgressSlice';
import { report, markStepStarted, clearStepTiming } from '../telemetry';
import { onboardingBus, type OnboardingEvent } from '../eventBus';
// (gate bump done via onboardingBus.resetReplayGate at runStep entry)
import { waitForSelector, resolveSelector } from '../selectors';
import {
spawnGlowRect,
@@ -42,29 +34,14 @@ interface RunContext {
signal: AbortSignal;
silent: boolean; // suppress popups during dependency re-walks
stepId: string;
// Resolver function for finding a step by id (avoids circular import).
findStep: (id: string) => OnboardingStep | undefined;
// Cleanup for the highlight_section big glow.
highlightCleanup: { current: (() => void) | null };
// Wall-clock timestamp the current popup was shown at, or null if no
// popup is active. Used by ensurePopupDwell to guarantee every popup
// stays visible for at least MIN_POPUP_DWELL_MS before being replaced
// or cleared by the next auto-transition op.
popupShownAt: { current: number | null };
}
// Minimum time every popup stays visible before an auto-transition
// (move_to, click, type_into, drag_select, outro) or a popup replacement
// is allowed to clear it. user-driven transitions (wait_user resolving)
// also flow through here, but typically the user has already been
// reading for longer than this anyway. 6 s = streaming typewriter
// cadence + ~3 s post-stream read time, which was the user-asked floor
// for popups that don't require an explicit user action to advance.
// 6s = streaming typewriter cadence + ~3s post-stream read time; floor for popups that auto-transition without an explicit user action.
const MIN_POPUP_DWELL_MS = 6000;
// Resolves once `ms` has elapsed or the signal aborts (whichever
// comes first). Used inside ensurePopupDwell so a step cancel doesn't
// hang on a popup that just appeared.
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) return Promise.resolve();
if (signal.aborted) return Promise.resolve();
@@ -81,8 +58,6 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
});
}
// Awaits the remaining minimum dwell time for the currently-displayed
// popup. No-op if no popup is active or the dwell has already elapsed.
async function ensurePopupDwell(ctx: RunContext): Promise<void> {
const shownAt = ctx.popupShownAt.current;
if (shownAt == null) return;
@@ -99,9 +74,6 @@ export interface RunStepArgs {
accentColor: string;
signal: AbortSignal;
findStep: (id: string) => OnboardingStep | undefined;
// Optional gate — if step.dependsOn[i] doesn't need re-walking (the
// dependency's outcome is still satisfied), the caller passes a function
// that returns true to skip it.
isDependencySatisfied?: (depId: string) => boolean;
}
@@ -111,10 +83,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
store.dispatch(setRunning(true));
store.dispatch(setCurrentStep(step.id));
markStepStarted();
// Bump the bus replay gate so any cached emits from prior steps (or
// the user's exploration in between) can't accidentally satisfy this
// step's wait_user gates. Subsequent once() subscriptions will only
// match emits that happen AFTER this bump.
// Bump bus replay gate so cached emits from prior steps can't satisfy this step's wait_user gates.
onboardingBus.resetReplayGate();
report('step_started', { step_id: step.id, stage: step.stage });
@@ -136,11 +105,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
try {
await ac.fadeIn(spawnPoint);
// Pre-flight: if the step needs a dashboard route and the user is on
// a different page (Settings closed but they're on /actions, /skills,
// etc), walk them into a dashboard first. Without this, the very
// first move_to of step 3/4/5/6/8 hits a missing target and the
// cursor stalls or strands itself over unrelated UI.
// Walk user into a dashboard first when step needs one; otherwise the first move_to hits a missing target on /actions, /skills, etc.
if (step.requiresDashboard && !isInDashboardRoute()) {
await runOps(buildOpenDashboardOps(), ctx);
}
@@ -152,18 +117,10 @@ export async function runStep(args: RunStepArgs): Promise<void> {
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
// Brief framing popup so the user knows why the cursor is
// about to walk them through a previous step's flow (e.g.
// step 5 asking step 4 to re-open a browser because they
// closed the one they spawned originally).
ac.showPopup('Quick setup before we continue.');
ctx.popupShownAt.current = performance.now();
await sleep(700);
// Non-silent walk: show popups so the user understands what
// each move_to is asking. Previously silent=true meant the
// cursor wandered through the dep's ops with no labels —
// robust but confusing. Telemetry isn't bumped for op-level
// events to avoid double-counting (silent kept for that).
// Non-silent dep-walk so each move_to has a label; telemetry stays per-step to avoid double-count.
await runOps(depStep.ops, { ...ctx, silent: false, stepId: depStep.id });
}
}
@@ -172,13 +129,6 @@ export async function runStep(args: RunStepArgs): Promise<void> {
await runOps(step.ops, ctx);
report('step_completed', { step_id: step.id });
store.dispatch(markStepCompleted(step.id));
// Belt-and-suspenders: dispatch clearJustCompleted from the runtime
// 950ms after the celebration starts. The OnboardingPanel ALSO has
// its own useEffect timer for this, but the runtime-side timer
// guarantees the celebration unsticks even if the panel's effect
// gets cancelled by a re-render race or AnimatePresence interaction
// — both dispatches go through the same idempotent reducer, so
// double-firing is harmless.
window.setTimeout(() => {
const cur = store.getState().onboardingProgress;
if (cur?.justCompletedStepId === step.id) {
@@ -200,10 +150,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
report('step_error', { step_id: step.id, error: msg });
}
// Re-show the panel IMMEDIATELY so the user sees it slide back in
// alongside the cursor's friendly retreat. Otherwise the panel
// stays hidden through the 1.8s recovery popup + fadeOut, which
// looks like the onboarding has crashed.
// Re-show panel immediately; otherwise it stays hidden through the 1.8s recovery popup + fadeOut, looking like a crash.
store.dispatch(setRunning(false));
try {
@@ -215,9 +162,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
// Diagnostic: surface a short version of the actual error in
// the recovery popup so we can see WHY the step bailed without
// needing DevTools open. 180-char cap keeps it readable.
// Surface short error in recovery popup; 180-char cap keeps it readable.
const isAbortErr =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const errSnippet = isAbortErr
@@ -226,11 +171,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
const debugSuffix = errSnippet
? `\n\n[debug] ${errSnippet}`
: '';
// Stash the full error on window so a dev can grab it from
// DevTools (`window.__OPENSWARM_LAST_ONBOARDING_ERR__`) even
// if the streaming popup hides the suffix. Full untruncated
// message + stack lives here, the 180-char snippet is just
// for the popup.
// Stash full untruncated error + stack on window for DevTools; popup only shows the 180-char snippet.
try {
(window as any).__OPENSWARM_LAST_ONBOARDING_ERR__ = {
step_id: step.id,
@@ -246,33 +187,20 @@ export async function runStep(args: RunStepArgs): Promise<void> {
err,
);
} catch {
/* defensive never let diagnostics throw */
/* defensive; never let diagnostics throw */
}
ac.showPopup(
"No worries, feel free to explore. Tap Show me whenever you're ready." +
debugSuffix,
);
// ACPopup streams text at ~30 ms/char + ~210 ms per punctuation
// mark, so a 240-char popup (base copy + 180-char debug
// suffix) takes ~10 s just to finish streaming. With a 5 s
// dwell the [debug] line never even appears on screen before
// the popup closes — which is why the user saw only the base
// recovery copy in every failure run. 14 s gives the streamer
// time to finish AND leaves a few seconds for the user to
// actually read the diagnostic line.
// 14s: ACPopup streams at ~30ms/char + ~210ms/punct, so a 240-char popup takes ~10s to finish streaming; needs time for streamer + read.
await new Promise<void>((r) => window.setTimeout(r, 14000));
}
} catch {
/* defensive never let cleanup throw */
/* defensive; never let cleanup throw */
}
// Retreat to the original spawnPoint — that's the icon's home
// position from before the panel hid itself, and after the
// setRunning(false) above the panel slides back to that exact spot.
// We previously re-read the live icon rect here, but that fires
// mid-slide-animation and yields transient coordinates (sometimes
// (0,0) if Framer hasn't applied the transform yet) — which is
// why the cursor was landing in the title-bar / kill-button area.
// Retreat to original spawnPoint; re-reading the live icon rect here yields transient coords mid-slide-animation (sometimes (0,0)).
try {
await ac.fadeOut(spawnPoint);
} catch {
@@ -294,9 +222,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
if (ctx.signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
// Op-level telemetry — gives drop-off granularity beyond
// step_started / step_completed. Skipped during silent dependency
// re-walks to avoid double-reporting.
if (!ctx.silent) {
report('op_started', {
step_id: ctx.stepId,
@@ -324,10 +249,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
duration_ms: Date.now() - opStart,
error: String(err),
});
// Console-visible breadcrumb so a dev with DevTools open can
// see WHICH op of WHICH step blew up without parsing telemetry.
// The catch in runStep above selectively logs based on error
// kind — this is more reliable and pinpoints the failing op.
// eslint-disable-next-line no-console
console.error(
`[onboarding] op failed: step=${ctx.stepId} op#${i}=${op.kind} ` +
@@ -343,13 +264,7 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const { ac, store, signal, accentColor } = ctx;
// Ops that physically move the cursor or change context implicitly
// clear any active popup, sticky tracker, AND active highlight glow —
// the previous instruction / pin / glow no longer applies once the
// cursor is heading somewhere new. wait_user / delay / popup /
// highlight_section / multi_choice keep all three visible (in
// particular, wait_user keeps tracking so the cursor stays glued to
// its target while we wait for the user's click).
// Physically-moving ops clear popup/tracker/glow; wait_user/delay/popup/highlight_section/multi_choice keep them.
const clearsTransients =
op.kind === 'move_to' ||
op.kind === 'click' ||
@@ -357,12 +272,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
op.kind === 'drag_select' ||
op.kind === 'outro';
if (clearsTransients) {
// Hold the previous popup on screen for MIN_POPUP_DWELL_MS before
// letting the next auto-transition clear it. Without this, a fast
// sequence like `popup → delay 350 → move_to → click` would yank
// the bubble before the user has a chance to read it. wait_user
// gates aren't routed through here because they don't transition
// until the user acts.
// Hold previous popup for MIN_POPUP_DWELL_MS before next auto-transition clears it; otherwise fast popup -> delay -> move_to sequences would yank the bubble before the user can read it.
await ensurePopupDwell(ctx);
ac.hidePopup();
ctx.popupShownAt.current = null;
@@ -375,57 +285,31 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
switch (op.kind) {
case 'move_to': {
// Pre-flight order matters: open the whole sidebar first (so
// sub-section markers exist in DOM), THEN check the Customization
// collapse, THEN target.
//
// Sidebar collapsed case ("AC freezes when user had sidebar
// hidden") — without this guard, waitForSelector for any
// sidebar-* target would hit its 2.5s lost-target timeout because
// the entire panel is unrendered.
// Order matters: open the whole sidebar first (sub-section markers must exist in DOM), THEN expand Customization, THEN target.
const expandSidebarOps = maybeBuildExpandSidebarOps(op.target);
if (expandSidebarOps) {
await runOps(expandSidebarOps, ctx);
}
// Customization collapsed case ("asks me to click on it twice")
// — without this guard, AC's popup pointed at an Actions/Skills/
// Modes item that wasn't yet visible, the user would click
// Customization to reveal it (which didn't satisfy the wait),
// then click the item, looking like a duplicate prompt.
const expandOps = maybeBuildExpandCustomizationOps(op.target);
if (expandOps) {
await runOps(expandOps, ctx);
}
const el = await waitForSelector(op.target);
const scrolled = scrollIntoViewIfNeeded(el);
// Cheaper rect-settle: instead of unconditionally sleeping 180ms
// after every scroll AND a possible 200ms retry, read the rect
// immediately and only wait if it actually looks bad. In the
// happy path (target already in view, layout stable), this skips
// both sleeps entirely.
const offX = op.offset?.x ?? 0;
const offY = op.offset?.y ?? 0;
const TITLE_BAR_BOTTOM = 38;
// "Truly broken" rect = zero size or pinned in title bar. NOT
// "below viewport" — that just means a smooth-scroll is still in
// progress. Treating below-viewport as degenerate caused step 2
// to abort with the recovery message every time the YouTube row
// was below the fold and AC had to scroll-then-pin.
// Broken = zero size or pinned in title bar; off-viewport just means smooth-scroll is mid-flight (don't treat as broken).
const isBroken = (rr: DOMRect, y: number): boolean =>
y < TITLE_BAR_BOTTOM ||
rr.width === 0 ||
rr.height === 0;
// Off-viewport but valid — element exists, scroll just hasn't
// landed it yet. Worth waiting through, not an abort condition.
const isOffViewport = (y: number): boolean =>
y > window.innerHeight || y < 0;
let r = el.getBoundingClientRect();
let cx = r.left + r.width / 2 + offX;
let cy = r.top + r.height / 2 + offY;
// Active poll for scroll-settle. Smooth-scrolls take 250-500ms;
// poll the rect every 60ms up to 1s. Bails the moment the element
// is in viewport with a non-broken rect, so the happy path stays
// fast (single poll, immediate exit).
// Poll scroll-settle every 60ms up to 1s (smooth-scrolls take 250-500ms); bail early when in viewport with non-broken rect.
const SCROLL_SETTLE_MAX_MS = 1000;
const POLL_MS = 60;
const startedAt = performance.now();
@@ -439,24 +323,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (!isBroken(r, cy) && !isOffViewport(cy)) break;
}
}
// Only abort if the rect is BROKEN after the settle window —
// off-viewport at this point means the scroll never landed,
// which usually means the page hasn't fully rendered yet, but
// pinning the cursor off-screen is harmless (user just sees
// nothing land for a moment).
if (isBroken(r, cy)) {
throw new Error(`waitForSelector: "${op.target}" rect did not settle`);
}
// Rect-stability check: when the user clicks "+" to open the
// dock chat, the chat input mounts then nudges into final
// position over a couple frames as siblings render. If we read
// the rect during that window and start the spring immediately,
// the cursor lands on a stale-target location and then the
// tracker has to drag it the remaining ~10-30px — visible as
// a "jump" right after the spring lands. Polling the rect for
// 2 stable consecutive frames (within 1.5px) guarantees we
// start the spring against the FINAL position. Capped at 200ms
// so we never block visibly. Most paths break out in 0-2 frames.
// Wait 2 stable frames before reading final rect; targets like the dock chat input nudge into position over a few frames after mount, and a stale-rect spring lands ~10-30px off and visibly jumps.
const STABILITY_MAX_MS = 200;
const STABILITY_THRESHOLD_PX = 1.5;
const stabilityStart = performance.now();
@@ -483,23 +353,13 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
prevCy = cy;
}
await ac.moveTo(cx, cy);
// One-frame yield before handing transform control to the
// sticky-tracker rAF. Without this, the tracker's first tick
// can fire while Framer's spring is still settling the final
// ~10px of the move, and the tracker's controls.set() cancels
// the spring mid-overshoot — visible as the cursor "teleporting"
// or disappearing into the destination. A single rAF lets the
// spring resolve before the tracker starts re-pinning every
// frame, which is when the cursor needs to start tracking
// anyway.
// rAF yield lets Framer's spring resolve before tracker's controls.set() cancels it mid-overshoot; otherwise cursor "teleports" into destination.
await new Promise<void>((r) => requestAnimationFrame(() => r()));
ac.startTracking(op.target, op.offset);
return;
}
case 'popup': {
if (ctx.silent) return;
// Replacing a popup-with-popup also has to honor the dwell floor,
// otherwise back-to-back popups would flash by too fast to read.
await ensurePopupDwell(ctx);
ac.showPopup(op.text);
ctx.popupShownAt.current = performance.now();
@@ -507,7 +367,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'multi_choice': {
if (ctx.silent) return;
// Multi-choice supersedes any showing popup. Same dwell floor.
await ensurePopupDwell(ctx);
ctx.popupShownAt.current = null;
const id = await ac.showMultiChoice(op.question, op.options);
@@ -529,33 +388,21 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'highlight_section': {
const el = await waitForSelector(op.target);
// Replace any previous highlight first so we don't stack glows.
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
const cleanup = spawnGlowRect(el, accentColor);
ctx.highlightCleanup.current = cleanup;
// Only show the popup if one was supplied — the runtime relies on
// the next op (typically wait_user) to keep the glow visible while
// the user reads. The glow is cleared by the next clearsTransients
// op (move_to / click / type_into / drag_select / outro) or at
// step-end in the runStep finally block.
if (op.popup && !ctx.silent) {
await ensurePopupDwell(ctx);
ac.showPopup(op.popup);
ctx.popupShownAt.current = performance.now();
}
// Optional minimum dwell so very-fast paths still register the
// glow visually. Defaults to a short beat; explicit durationMs
// overrides.
await sleep(op.durationMs ?? 600);
return;
}
case 'type_into': {
// Resolve text up-front — string-or-function. Function form lets a
// step pick its prompt at run-time based on current Redux state
// (e.g. step 3's YouTube vs. web-research fallback).
const resolvedText =
typeof op.text === 'function' ? op.text(ctx.store.getState()) : op.text;
const targetTrimmed = resolvedText.trim();
@@ -567,18 +414,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return (e.textContent ?? '').trim();
};
// Type-and-verify is wrapped in a retry loop because the App
// Builder's chat input can be detached out from under us mid-
// stream: the workspace's `runtime/start → stop → start` cycle +
// ViewEditor's seed-then-navigate causes React to swap the
// AgentChat instance the user can see, leaving the element our
// `el` ref points at detached from the DOM. execCommand fires
// silently into the dead node, no text lands, hasContent stays
// false, and the send button never renders — which is what was
// pushing the wizard into the recovery popup. On a verify-miss
// we re-fetch the selector (which now resolves to the FRESH
// AgentChat's input) and type again. Two attempts is the max —
// a real "the input is genuinely broken" case shouldn't loop.
// Retry loop: App Builder's ViewEditor remounts can detach the chat input mid-type; re-fetch selector and retype.
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const el = await waitForSelector(op.target);
@@ -593,38 +429,28 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Let React's onInput commit land before verifying. 80 ms is
// enough in the warm-path; we sleep longer between retries
// because a remount window is what we're racing.
// 80ms lets React's onInput commit land in the warm path.
await sleep(80);
if (!targetTrimmed) return;
// Re-fetch in case the original `el` was detached by a remount.
// resolveSelector will return whatever the CURRENT canonical
// chat-input is in the scope priority order.
// Re-fetch in case original `el` was detached by remount.
const currentEl = resolveSelector(op.target);
const verifyEl = currentEl ?? el;
const landed = readText(verifyEl);
if (landed.length >= Math.floor(targetTrimmed.length * 0.8)) {
// Success — text is in the live input.
return;
}
if (attempt < MAX_ATTEMPTS) {
// eslint-disable-next-line no-console
console.warn(
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS} typed=${landed.length}/${targetTrimmed.length}, retrying`,
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS}; typed=${landed.length}/${targetTrimmed.length}, retrying`,
);
// Wait long enough for any in-flight remount + reconcile to
// settle. 600 ms is longer than the ~500 ms stability window
// wait_for_dom uses, so by the time we retry the DOM is in
// its steady state.
// 600ms > the ~500ms stability window wait_for_dom uses, so DOM is in steady state by retry.
await sleep(600);
continue;
}
// Final attempt — same single-shot re-insert the old anti-
// revert guard used, against whatever element is current.
if (verifyEl.isContentEditable) {
verifyEl.focus();
const range = document.createRange();
@@ -646,19 +472,14 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
verifyEl.dispatchEvent(new Event('input', { bubbles: true }));
}
}
// One last verify after the fallback — if text STILL didn't land,
// throw with a descriptive error so the wizard's catch block
// shows a useful diagnostic instead of letting the next op
// (move_to chatSendButton) burn 15 s on a button that will
// never render because hasContent is false. The thrown message
// appears in DevTools console via the op-failed breadcrumb.
// Throw descriptive error so wizard's catch shows diagnostic instead of letting next op burn 15s on a button that never renders (hasContent=false).
await sleep(120);
const finalLanded = readText(resolveSelector(op.target) ?? verifyEl);
if (finalLanded.length < Math.floor(targetTrimmed.length * 0.5)) {
throw new Error(
`type_into: text never landed in "${op.target}" after ` +
`${MAX_ATTEMPTS} attempts (final length=${finalLanded.length}/${targetTrimmed.length}). ` +
`The chat input was probably detached by an in-flight remount ` +
`The chat input was probably detached by an in-flight remount; ` +
`check whether ViewEditor's seed-then-navigate is firing twice ` +
`or whether AgentChat's session key is swapping mid-stream.`,
);
@@ -678,15 +499,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
// Disabled-button guard. If the resolved element (or any
// ancestor IconButton/Button wrapper) is in a disabled state
// when we go to fire the synthetic click, the click is a
// no-op AND we silently move on — which is the "AC clicks
// send and nothing happens" bug for step 6 (the contentEditable
// chat input sometimes reverts AC's typed text under load,
// leaving the send button disabled at click time). Detect it
// and try a brief recovery: wait one frame and re-check, in
// case the button just-now-enabled because state landed late.
// Disabled-button guard: synthetic click on disabled wrapper is silent no-op (step 6 "send does nothing"); wait one frame in case state lands late.
const isDisabled = (n: HTMLElement | null): boolean => {
while (n) {
if (n.hasAttribute('disabled')) return true;
@@ -704,17 +517,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
try {
el.click();
} catch {
/* swallow degrade to visual-only */
/* swallow; degrade to visual-only */
}
}
// Do NOT start tracking after a click. Many click targets are
// ephemeral — chat send buttons morph into stop buttons after
// submit, modal triggers unmount when the modal opens, etc.
// Tracking a disappearing element triggers lost-target → step
// abort, which kills the step before outro runs and prevents
// markStepCompleted from firing (the user is stuck on the same
// step forever). The cursor's last-set position from moveTo holds
// steady until the next op explicitly moves it.
// Do NOT startTracking after a click: many targets are ephemeral (send button -> stop button, modal trigger unmounts), and tracking a vanishing element trips lost-target -> step abort -> markStepCompleted never fires.
return;
}
case 'drag_select': {
@@ -722,13 +528,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
// Rect-stability poll. Without this, the dashed selection box is
// drawn at coordinates read mid-animation — e.g. when step 6
// clicks fit-to-view right before this op, the camera is still
// panning and the target's viewport rect changes frame-to-frame.
// Result: a box that's the wrong size or offset from the actual
// card. Wait for 2 stable consecutive frames (within 1.5px) up
// to 500ms before reading the final rect.
// Wait 2 stable frames before reading final rect; e.g. step 6's fit-to-view mid-pan changes target rect frame-to-frame and yields a misaligned selection box.
let r = el.getBoundingClientRect();
const stableStart = performance.now();
let prevLeft = r.left;
@@ -750,13 +550,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const toX = r.right + 12;
const toY = r.bottom + 12;
await ac.moveTo(fromX, fromY);
// Run the cursor and the dashed-rect animation in parallel, so the
// cursor visually leads the selection from top-left to bottom-right
// (matching how a real drag works) instead of stranding itself at
// the start corner while the box draws itself across the target.
// The cursor uses a 600ms tween with the same cubic-bezier the rect
// uses (ACGestures.ts) so the two motions stay in lock-step. Spring
// physics here would overshoot and desync from the CSS transition.
// Cursor + rect animate in parallel with matching cubic-bezier (ACGestures.ts) so they stay in lock-step; spring physics would overshoot and desync.
const RECT_DURATION_MS = 600;
await Promise.all([
animateDragSelect(
@@ -769,9 +563,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ease: [0.4, 0, 0.2, 1],
}),
]);
// No tracking after drag_select — the visual ends at a calculated
// bottom-right corner, not the center of any element. Next op
// (typically wait_user or move_to) takes over positioning.
return;
}
case 'wait_user': {
@@ -781,19 +572,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
store,
op.timeoutMs,
);
// Retry-on-timeout for event_bus waits only: those fire on real
// user actions (browser:spawned, skill:installed, chat:message_sent,
// agent:attached_to_browser) — if the event never arrived the
// step's actual goal didn't happen, so silently marking the step
// done would let the user proceed against a half-broken state.
// One retry with a "didn't seem to go through" popup gives the
// user a clear chance to redo the action; if it times out a
// second time, we soft-succeed (same as before) so the step
// doesn't strand them forever.
//
// click_target + redux_predicate timeouts keep the original
// soft-success policy: the user might legitimately have done
// the underlying thing without our listener catching it.
// Retry on event_bus timeout only: those fire on real user actions, so silent soft-success would leave them in a half-broken state. click_target + redux_predicate keep soft-success (listener may have just missed).
if (first.timedOut && op.condition.kind === 'event_bus') {
report('wait_user_retry_prompted', {
step_id: ctx.stepId,
@@ -809,34 +588,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
);
}
ac.hidePopup();
// CRITICAL: stop tracking the previous move_to target now that
// the user has engaged with it. Many `wait_user click_target`
// targets are ephemeral — the App Builder's `+ New app` button
// disappears the instant the user clicks it (Views.tsx swaps
// ViewEditor in), and if the tracker keeps watching that now-
// disconnected element, the lost-target watchdog fires after
// 2.5 s and aborts the entire step (step 8 was aborting before
// it ever reached `type_into` for this exact reason — the
// `[onboarding] step make_app aborted: lost-target` console
// line pointed at `apps-new-button`, not at chat-input). The
// tracker for the NEXT target (chat-input, send button, etc.)
// starts in the next move_to / type_into op.
// CRITICAL: stop tracking previous target; many wait_user click_target's are ephemeral (App Builder's "+ New app" unmounts on click) and the 2.5s lost-target watchdog would abort the step before the next op runs.
ac.stopTracking();
// The user just did the thing — they don't need a dwell floor on
// top of having engaged with the popup. Clearing popupShownAt
// makes the next op's clearsTransients block a no-op for dwell,
// so the cursor starts moving toward the next target the instant
// the click registers. Without this, the cursor sat idle for up
// to MIN_POPUP_DWELL_MS while the next op's click listener was
// unregistered — so a quick follow-up click (e.g. clicking the
// chat-input select-mode toggle right after opening the chat)
// was being dropped on the floor, and the user saw "Show me"
// reset because the wait never resolved.
// Clear dwell: user already engaged with popup, so next op can move immediately. Without this, a quick follow-up click was dropped while the next listener was still being registered.
ctx.popupShownAt.current = null;
// Quick layout-settle — one frame is enough in 95% of cases
// (React commits on the next animation frame). The move_to
// op also has its own settle if the rect comes out degenerate,
// so this is just a cheap "let the click handler run" beat.
await sleep(16);
return;
}
@@ -855,19 +610,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
case 'wait_for_dom': {
const timeoutMs = op.timeoutMs ?? 8000;
const POLL_MS = 100;
// Stability gate: the matched element has to be the SAME node for
// STABILITY_POLLS consecutive polls (≈ 500 ms continuous presence)
// before we return success. Without this, step 8 was finding the
// App Builder's chat-input on poll N, returning, then the next
// op's typing ran straight into AgentChat's remount (the
// `runtime/start → stop → start` cycle from a draftLaunchMap swap
// + React Strict Mode double-effect) — the input became detached
// mid-stream, execCommand('insertText') silently no-op'd into the
// dead node, no text landed, hasContent stayed false, the send
// button was never rendered, and the wizard's next move_to
// chatSendButton burned its 15 s waitForSelector and threw into
// the recovery popup. Requiring stable identity walls off the
// remount window so we only proceed once the runtime has settled.
// Stability gate: same node identity for STABILITY_POLLS consecutive polls (~500ms) walls off AgentChat's runtime/start->stop->start remount; otherwise typing lands in a detached node and silently no-ops.
const STABILITY_POLLS = 5;
const startedAt = performance.now();
let stableEl: Element | null = null;
@@ -891,11 +634,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
await sleep(POLL_MS);
}
// Hard error on timeout, with DOM-state diagnostics so the dev
// console tells us WHY the selector didn't match — bare selector
// mismatch vs. the marker being on the right element but the
// wrong scope vs. nothing in DOM at all are three different bugs
// and we couldn't tell which from "step failed".
// Timeout error includes scope diagnostics: selector-mismatch vs. wrong-scope vs. nothing-in-DOM are three different bugs that "step failed" can't distinguish.
const scopeEls = Array.from(
document.querySelectorAll('[data-onboarding-scope]'),
).map((e) => (e as HTMLElement).getAttribute('data-onboarding-scope'));
@@ -925,11 +664,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
}
// Bring the target into view if any part of it is outside the viewport.
// Returns true if a scroll was actually triggered, false otherwise — the
// runtime uses this to decide whether to wait the smooth-scroll-settle
// beat. Scrolling-already-visible-element + 180ms wait would be pure
// added latency on every cursor move (~10s across the whole tour).
/** Returns true if a scroll was triggered; runtime uses this to skip the smooth-scroll-settle wait on already-visible targets (~10s saved across the tour). */
function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
@@ -943,45 +678,22 @@ function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
try {
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
} catch {
// Older webview / jsdom — fall back to instant scroll.
try {
el.scrollIntoView();
} catch {
/* nothing to do — tracker will still try to pin once visible */
/* tracker will still try to pin once visible */
}
}
return true;
}
// True when the current URL is `#/dashboard/<id>` (a specific dashboard,
// where the toolbar with + / browser / etc. mounts). False on `#/`
// (dashboard list), `#/skills`, etc. HashRouter only — production app
// uses HashRouter so window.location.hash is the source of truth.
//
// Note: path is singular `/dashboard/`, not `/dashboards/` — that mismatch
// previously had the runtime thinking the user was always in a dashboard
// (since neither shape ever matched), which is why "Show me" from the
// Actions/Skills pages would barrel into a missing-+ button.
// HashRouter path is singular `/dashboard/`, not `/dashboards/`; mismatch previously had runtime always-in-dashboard.
function isInDashboardRoute(): boolean {
const h = window.location.hash || '';
return /^#\/dashboard\/[^/?#]+/.test(h);
}
// Ops the runtime prepends when a step requires being inside a dashboard
// but the user isn't. State-aware: reads the live DOM to skip sub-steps
// the user has already satisfied, so we never force a click that would
// undo the desired state (e.g. clicking the Dashboards section header
// when it's already expanded — which would collapse it).
//
// The two sub-conditions:
// 1. Sidebar Dashboards section is expanded (so rows are visible).
// Marked via data-expanded="true" / aria-expanded="true" on the
// ListItemButton in AppShell.
// 2. The user has clicked into a dashboard (route #/dashboard/<id>).
//
// If (1) is already met, we skip the section-click. If (2) is met, we
// don't run any of these ops at all — the caller already gates on
// isInDashboardRoute().
// State-aware: skips section-click when Dashboards is already expanded so we don't collapse it.
function buildOpenDashboardOps(): ACOp[] {
const sectionEl = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-dashboards"]',
@@ -1014,27 +726,13 @@ function buildOpenDashboardOps(): ACOp[] {
return ops;
}
// Set of targets that live INSIDE the sidebar's Customization collapse.
// If a step's move_to points at one of these and the section is closed,
// the user can't see (or click) the target — they'd have to click
// Customization first to expand it. The runtime checks this before each
// move_to and, if needed, walks the user through the expand-click first.
// Same pattern as buildOpenDashboardOps: state-aware, no redundant clicks.
const CUSTOMIZATION_AREA_TARGETS = new Set<string>([
'sidebar-actions',
'sidebar-skills',
'sidebar-modes',
]);
// Targets that live anywhere inside the sidebar (top-level nav rows,
// section headers, items revealed by an expanded section). If a step's
// move_to points at one of these and the WHOLE sidebar is collapsed
// (the AppShell ViewSidebar toggle hides the entire panel), the target
// element isn't in the DOM at all and waitForSelector would freeze the
// AC for a full 2.5s lost-target timeout before giving up.
//
// `sidebar-toggle` is deliberately excluded — it lives in the top bar
// and is the thing we click to expand. Recursing on it would loop.
// `sidebar-toggle` excluded: it lives in the top bar (we click it to expand). Recursing would loop.
const SIDEBAR_AREA_TARGETS = new Set<string>([
'sidebar-settings-button',
'sidebar-dashboards',
@@ -1046,52 +744,22 @@ const SIDEBAR_AREA_TARGETS = new Set<string>([
'dashboard-row-first',
]);
/**
* If the requested target lives inside the sidebar panel and the panel
* is currently collapsed (aria-expanded="false" on the top-bar
* ViewSidebar toggle), return ops to walk the user through clicking the
* toggle. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*
* This guard MUST run before maybeBuildExpandCustomizationOps because
* the Customization header itself lives inside the collapsible panel —
* checking for an expanded Customization on a hidden panel would always
* read "not expanded" and queue an impossible click.
*/
/** MUST run before maybeBuildExpandCustomizationOps: Customization header is inside the collapsible panel, so expand-check on hidden panel queues an impossible click. */
function maybeBuildExpandSidebarOps(target: string): ACOp[] | null {
if (!SIDEBAR_AREA_TARGETS.has(target)) return null;
const toggle = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-toggle"]',
);
// aria-expanded reflects !sidebarCollapsed (true = sidebar visible).
// Missing / undefined means we couldn't find the toggle — assume
// visible and let waitForSelector handle the (unlikely) real failure
// so we don't gate on a missing marker.
// Missing toggle: assume visible and let waitForSelector handle the unlikely real failure.
const expanded =
toggle?.getAttribute('aria-expanded') === 'true' || toggle === null;
if (expanded) return null;
// Auto-expand: simulate-click the toggle. Previously we asked the
// user to click it themselves, which fell over in two ways: (1) if
// the AC's popup positioning glitched on collapsed-layout shift, the
// user saw the cursor freeze with no obvious instruction, and (2) the
// user shouldn't have to undo their own sidebar collapse to continue
// onboarding anyway. simulate:true fires the React onClick on the
// IconButton, the sidebar slides open, and the original move_to
// continues against the now-mounted target.
return [
{ kind: 'click', target: 'sidebar-toggle', simulate: true },
// Sidebar slide-in is ~200ms; the small delay lets the slide
// animation land before the next move_to reads rects.
{ kind: 'delay', ms: 260 },
];
}
/**
* If the requested target lives inside the Customization collapse and the
* section is currently closed, return ops to walk the user through
* expanding it. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*/
function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null {
if (!CUSTOMIZATION_AREA_TARGETS.has(target)) return null;
const header = document.querySelector<HTMLElement>(
@@ -1146,9 +814,6 @@ function waitForCondition(
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
// Surface the timeout to the caller so wait_user can decide
// whether to soft-succeed (the previous policy) or prompt the
// user to retry (the event_bus path — see wait_user handler).
finish(true);
}, timeoutMs);
}
@@ -1,17 +1,4 @@
// Module-level signal for the cursor's logical position. Both the
// AgenticCursor component (which renders the arrow) and ACPopup /
// ACMultiChoice (which need to render relative to it) read from here.
//
// Performance contract: the cursor itself is driven by Framer Motion's
// imperative `controls.set`, which doesn't trigger React renders. This
// store exists ONLY so popups can follow during animation. Subscribers
// re-render on every notification, so naive frame-rate notifications
// would re-render the popup 60 times/sec — wasteful since popup
// position barely changes between sub-pixel cursor frames.
//
// We coalesce position writes to ~30fps via rAF and only notify when
// the cursor has moved more than COALESCE_PX. Visibility flips are
// flushed immediately (rare event, user-visible).
// Logical cursor position store; rAF-coalesced to ~30fps so popups don't re-render every frame.
import { useSyncExternalStore } from 'react';
@@ -25,9 +12,7 @@ let state: CursorPos = { x: 0, y: 0, visible: false };
let pendingState: CursorPos | null = null;
const listeners = new Set<() => void>();
// Sub-pixel cursor moves don't change popup position visibly, but they
// still trigger React renders. 1.5px is enough to feel smooth without
// re-rendering on every frame.
// 1.5px: smooth-feeling threshold that avoids per-sub-pixel React renders.
const COALESCE_PX = 1.5;
let rafScheduled = false;
@@ -44,8 +29,7 @@ export const cursorStore = {
set(next: Partial<CursorPos>) {
const merged = { ...(pendingState ?? state), ...next };
// Visibility transitions bypass coalescing — these are user-visible
// mounts/unmounts of popups, must flush immediately.
// Visibility transitions bypass coalescing (mounts/unmounts must flush immediately).
const visibilityChanged = merged.visible !== state.visible;
const dx = Math.abs(merged.x - state.x);
const dy = Math.abs(merged.y - state.y);
@@ -60,8 +44,7 @@ export const cursorStore = {
}
if (!significantMove) {
// Below threshold: update pending state silently. The next
// significant move will pick up the latest pending values.
// Below threshold: stash silently; next significant move will pick up these pending values.
pendingState = merged;
return;
}
@@ -1,12 +1,4 @@
// Tiny mitt-style event bus for onboarding-v2 advance conditions that
// don't have a natural Redux signal. Each emit site is a one-liner at the
// success path of a feature (browser:spawned at the end of spawnBrowser,
// settings:closed when the modal closes, etc).
//
// Why not Redux for everything: some events (browser navigated, app
// generation milestones) involve backend round-trips and the Redux state
// lags by a tick. Explicit emit at the success callsite is more
// deterministic than observing state.
// Mitt-style bus for onboarding-v2 advance conditions without a clean Redux signal.
export type OnboardingEvent =
| 'browser:spawned'
@@ -26,35 +18,18 @@ export type OnboardingEvent =
type Handler = (...args: unknown[]) => void;
// Replay window — see explanation on once() below. Tight on purpose so
// previous steps' emits can't accidentally satisfy current-step waits;
// the gating below is a stronger guarantee than the time window alone.
// Tight replay window; the gate below is the stronger guarantee against cross-step contamination.
const REPLAY_WINDOW_MS = 500;
class OnboardingBus {
private handlers = new Map<OnboardingEvent, Set<Handler>>();
// recentEmits stores the timestamp of the most recent emit per event.
// Used by once() to satisfy a subscription that races a synchronous
// emit (e.g. AC.click() → handleSend → emit happens BEFORE the next
// op's wait_user gets to register). Without this, the wait sits idle
// for its full timeout.
/** Most-recent-emit ts per event; lets once() satisfy a subscription racing a sync emit. */
private recentEmits = new Map<OnboardingEvent, number>();
// Monotonic gate id. Director bumps this whenever a new step starts;
// any once() subscriber that registers will only consider replays
// emitted after that bump. Solves the cross-step contamination case
// where step 6 emitted chat:message_sent ages ago and step 8's
// identical wait satisfies on the stale cached timestamp.
/** Monotonic gate bumped per new step; once() ignores emits older than the gate. */
private gateId = 0;
private gateTs = 0;
/**
* Bump the gate. Director calls this at the start of every new step
* (and at runStep cleanup). All recentEmits become invisible to
* subsequent once() subscribers — they only match emits that happen
* AFTER the bump. Also clears the recentEmits map outright as
* defense-in-depth — the gate alone would suffice but keeping a
* stale map around for hours is wasteful.
*/
/** Bump gate so subsequent once() subscribers only match emits after this point. */
resetReplayGate(): void {
this.gateId += 1;
this.gateTs = Date.now();
@@ -75,7 +50,6 @@ class OnboardingBus {
this.recentEmits.set(event, Date.now());
const set = this.handlers.get(event);
if (!set) return;
// Snapshot to avoid mutation during iteration.
[...set].forEach((h) => {
try {
h(...args);
@@ -86,10 +60,7 @@ class OnboardingBus {
}
once(event: OnboardingEvent, handler: Handler): () => void {
// Replay path: if this exact event was emitted within the last
// REPLAY_WINDOW_MS *AND* after the most recent gate bump, fire
// the handler now and don't register at all. The gate check is
// what prevents stale step-6 emits from satisfying step-8 waits.
// Replay: recent emit within window AND after the gate bump => fire now, skip registering.
const last = this.recentEmits.get(event);
if (
last !== undefined &&
@@ -115,9 +86,7 @@ class OnboardingBus {
export const onboardingBus = new OnboardingBus();
// Expose on window in dev for debugging — tests and the browser console
// can poke `window.__OPENSWARM_ONBOARDING_BUS__.emit('browser:spawned')`
// to advance steps without going through real product UI.
// Window-exposed for console debugging: __OPENSWARM_ONBOARDING_BUS__.emit('browser:spawned').
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_ONBOARDING_BUS__ = onboardingBus;
}
@@ -1,44 +1,27 @@
// Central registry of every data-onboarding (or data-select-type) string the
// onboarding v2 system targets. Step files import S.* — never inline literals
// — so a refactor that renames a selector breaks at type-check time and we
// can grep for usages.
//
// New keys added by v2 are commented; pre-existing keys (already wired in
// product code before v2) are noted with [existing].
// Central registry of data-onboarding / data-select-type selectors. Step files import S.*; never inline.
export const S = {
// [existing] sidebar / nav
sidebarSkills: 'sidebar-skills',
sidebarActions: 'sidebar-actions',
sidebarModes: 'sidebar-modes',
sidebarApps: 'sidebar-apps',
// new — sidebar
sidebarSettingsButton: 'sidebar-settings-button',
sidebarDashboards: 'sidebar-dashboards',
// The ViewSidebar icon in AppShell's top bar that hides/shows the
// whole sidebar. Wears aria-expanded={!sidebarCollapsed} so the
// runtime's expand-sidebar preflight can detect a collapsed state and
// walk the user through clicking it before targeting anything else
// in the sidebar.
/** Top-bar ViewSidebar toggle; aria-expanded drives the expand-sidebar preflight. */
sidebarToggle: 'sidebar-toggle',
// First row inside the expanded Dashboards section. The "click into a
// dashboard" hop targets this so the user lands inside a dashboard
// route (where the toolbar + and browser button actually exist).
/** First row in Dashboards section; "click into a dashboard" hop targets this. */
dashboardRowFirst: 'dashboard-row-first',
// [existing] dashboard toolbar
newAgentButton: 'new-agent-button',
browserButton: 'browser-button',
canvasControls: 'canvas-controls',
// new — dashboard toolbar
dashboardToolbarApps: 'dashboard-toolbar-apps',
// [existing] agent card
agentCard: 'agent-card', // matched via data-select-type as fallback
/** Matched via data-select-type as fallback. */
agentCard: 'agent-card',
// new — settings modal
settingsModelsTab: 'settings-models-tab',
settingsCloseButton: 'settings-close-button',
settingsProSection: 'settings-pro-section',
@@ -46,12 +29,10 @@ export const S = {
settingsApiKeys: 'settings-api-keys',
settingsRestartTour: 'settings-restart-tour',
// new — agent chat input
chatInput: 'chat-input',
chatSendButton: 'chat-send-button',
elementSelectionToggle: 'element-selection-toggle',
// new — actions / tools page
actionsRedditToggle: 'actions-reddit-toggle',
actionsRedditChevron: 'actions-reddit-chevron',
actionsSubredditsChevron: 'actions-subreddits-chevron',
@@ -59,51 +40,32 @@ export const S = {
actionsYoutubeToggle: 'actions-youtube-toggle',
actionsYoutubeChevron: 'actions-youtube-chevron',
// canvas controls toolbar — used by the inline tour-tip in step 5
// that flags fit-to-view / tidy / minimap once the user has multiple
// cards on the canvas.
canvasFitToView: 'canvas-fit-to-view',
canvasTidyLayout: 'canvas-tidy-layout',
canvasMinimapToggle: 'canvas-minimap-toggle',
// sidebar Customization section header — used by the runtime guard
// that auto-expands it before targeting Actions / Skills / Modes
// (which live inside the collapsed area).
/** Header for sidebar's Customization section; runtime auto-expands before targeting children. */
sidebarCustomization: 'sidebar-customization',
// new — skills page
skillItemPdf: 'skill-item-pdf',
skillInstallButton: 'skill-install-button',
skillBuilderFab: 'skill-builder-fab',
// new — apps / views page
appsNewButton: 'apps-new-button',
appCardLatest: 'app-card-latest',
// new — browser card
browserUrlBar: 'browser-url-bar',
} as const;
export type SelectorKey = (typeof S)[keyof typeof S];
// Selectors that may legitimately match multiple elements (one per agent
// card). For these we want the *newest* card — the one the user just
// spawned via the + button — not whichever agent happens to be earliest
// in DOM order. Without this scoping, step 6's "type into chat input"
// would hijack the existing "Open Swarm documentation" agent from step 5
// instead of the new orchestrator.
// Per-agent selectors resolve to the newest card so step 6 doesn't hijack step 5's agent.
const PER_AGENT_SELECTORS = new Set([
'chat-input',
'chat-send-button',
'element-selection-toggle',
]);
// Resolve a selector string to a live DOM node, falling back to data-select-type
// if data-onboarding doesn't match. Returns null if not found.
//
// Per-agent selectors get special treatment: querySelectorAll all matches
// and pick the one inside the LAST agent-card in DOM order (cards mount
// at the end as they're created, so the last is the newest). Single-match
// selectors are unchanged.
/** Resolve a selector to a DOM node; per-agent selectors pick the newest spawn. */
export function resolveSelector(target: string): HTMLElement | null {
const escaped = (window as any).CSS?.escape?.(target) ?? target;
@@ -114,11 +76,7 @@ export function resolveSelector(target: string): HTMLElement | null {
if (all.length === 0) return null;
if (all.length === 1) return all[0];
// Priority 1: the App Builder's AgentChat scope on /apps/. The
// App Builder mounts a regular AgentChat in the left pane —
// not wrapped in [data-select-type="agent-card"] — so without
// this explicit scope, step 8's chat-input would fall through
// to "last DOM match" and AC would type into nothing visible.
// Priority 1: App Builder's AgentChat scope. It mounts AgentChat without an agent-card wrapper.
const appBuilderScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="app-builder"]',
);
@@ -128,11 +86,7 @@ export function resolveSelector(target: string): HTMLElement | null {
);
if (scoped) return scoped;
}
// Priority 2: the dock toolbar's ChatInput, when open. This is the
// "draft agent" the user just opened by clicking + — higher
// priority than any existing agent-card so step 5/6's chat-input /
// send-button / element-selection-toggle ops route to the dock,
// not whichever agent-card is freshest in the DOM.
// Priority 2: dock toolbar's draft-ChatInput; outranks any existing agent-card.
const dockScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="dock"]',
);
@@ -143,9 +97,7 @@ export function resolveSelector(target: string): HTMLElement | null {
if (scoped) return scoped;
}
// Priority 2: the agent-card with the newest data-onboarding-spawn-ms
// (set from session.created_at). Used during/after the dock has been
// collapsed and a real agent card exists.
// Priority 3: agent-card with the newest data-onboarding-spawn-ms (after dock collapses).
const cards = document.querySelectorAll<HTMLElement>(
'[data-select-type="agent-card"]',
);
@@ -177,14 +129,7 @@ export function resolveSelector(target: string): HTMLElement | null {
return el;
}
// Wait for a selector to appear in the DOM. Resolves with the element, or
// rejects after timeoutMs. Used by acRuntime when a target is expected to
// mount asynchronously (e.g. settings modal, just-spawned card).
//
// Default bumped to 15s because under heavy main-thread load (many agents
// streaming, App Builder /apps/new mounting AgentChat with its own model
// probe + fetches), 8s was sometimes not enough and AC would abort into
// the recovery popup just before the target finally rendered.
/** Resolve when target mounts; 15s default to ride out heavy main-thread load on /apps/new. */
export function waitForSelector(
target: string,
timeoutMs = 15000,
@@ -205,8 +150,7 @@ export function waitForSelector(
}
});
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
// Also poll as a safety net — MutationObserver misses nothing in practice
// but the timeout path needs a way to fire even if the DOM is quiet.
// Poll as a safety net so the timeout path fires even if the DOM is quiet.
setTimeout(() => {
const el = resolveSelector(target);
if (el) {
@@ -1,7 +1,4 @@
// Shared skipIf predicates. Each returns true when the corresponding step
// is already-done in current Redux state — used to pre-mark completed
// milestones for upgrading users and to short-circuit "Show me" if the
// user already did the thing.
// skipIf predicates: true => step is already-done in current Redux state.
import type { RootState } from '@/shared/state/store';
import {
@@ -11,9 +8,7 @@ import {
export function hasModelConnected(s: RootState): boolean {
const d = s.settings.data as any;
if (!d) return false;
// Path 1: OpenSwarm Pro cloud bearer.
if (d.connection_mode === 'openswarm-pro' && d.openswarm_bearer_token) return true;
// Path 2: first-party API keys typed into Settings → Models.
if (
d.anthropic_api_key ||
d.openai_api_key ||
@@ -22,35 +17,22 @@ export function hasModelConnected(s: RootState): boolean {
) {
return true;
}
// Path 3: custom OpenAI-compatible providers (LM Studio, Ollama, etc.).
// Match the validity rule the Settings page uses to render the provider
// row: name + base_url present. The api_key field is intentionally
// optional — local OpenAI-compatible servers don't require one.
// Custom OpenAI-compatible providers; api_key optional for local servers.
const customs = (d.custom_providers || []) as any[];
if (customs.some((cp) => cp?.name?.trim() && cp?.base_url?.trim())) {
return true;
}
// Path 4: external OAuth subscriptions (Claude Max, ChatGPT, etc.). The
// tokens live in 9Router-managed storage and are surfaced to the frontend
// only via the subscriptionsSlice mirror of /agents/subscriptions/status.
if (hasAnyActiveSubscription(s)) return true;
return false;
}
export function hasAnyToolEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
// Match the Switch's read in Tools.tsx: `tool.enabled !== false`. Tools
// installed before the `enabled` field existed have it as undefined,
// which the Switch treats as "on" — so we should too. Otherwise step 2
// never auto-skips for users who already have integrations installed.
// Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on.
return Object.values(items).some((t: any) => t?.enabled !== false);
}
// True when a YouTube-shaped tool is currently enabled. Used by step 2's
// wait-for-toggle so the wait only resolves when YouTube is actually ON,
// regardless of how many times the user toggles. Step 2 uses YouTube to
// match the rest of the tour (step 3 prompts for a YouTube video summary,
// so enabling YouTube here is a coherent throughline).
/** True when a YouTube-shaped tool is on; step 2 waits on this so toggle-flapping stays in sync. */
export function isYoutubeEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
return Object.values(items).some((t: any) => {
@@ -72,11 +54,7 @@ export function hasAnySkillInstalled(s: RootState): boolean {
return Object.keys(items).length > 0;
}
// True if the PDF-handling skill is installed. Used by step 7 in place
// of hasAnySkillInstalled so installing any *other* skill doesn't
// auto-skip the PDF-specific install demo. Matches on id OR name OR
// command containing 'pdf' (case-insensitive) — the skill might land
// under any of those depending on how the user installed it.
/** True if PDF skill installed (id/name/command); step 7 uses this so other skills don't auto-skip. */
export function hasPdfSkillInstalled(s: RootState): boolean {
const items = s.skills?.items as any;
const list: any[] = Array.isArray(items) ? items : Object.values(items ?? {});
@@ -88,9 +66,7 @@ export function hasPdfSkillInstalled(s: RootState): boolean {
});
}
// True if any browser card exists on the canvas. Used by step 4 to
// auto-skip the "open a browser" walkthrough for users who already
// have one parked on their dashboard.
/** True if a browser card exists; step 4 auto-skips the open-a-browser walkthrough. */
export function hasAnyBrowserSpawned(s: RootState): boolean {
const cards = (s as any).dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
@@ -10,10 +10,7 @@ export const step02: OnboardingStep = {
description: 'Allow agents to work across your apps.',
videoSrc: './onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
// Narrowed from hasAnyToolEnabled → isYoutubeEnabled so users with
// an unrelated tool already on (e.g. Slack, Reddit) still get walked
// through enabling YouTube — step 3's hardcoded YouTube-summary
// prompt would otherwise hit a disabled MCP and stall.
// Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on.
skipIf: isYoutubeEnabled,
ops: [
{ kind: 'move_to', target: S.sidebarActions },
@@ -22,12 +19,7 @@ export const step02: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarActions },
},
// YouTube toggle. Picked YouTube here (instead of Reddit) so the
// tour has a consistent throughline — step 3 launches an Agent that
// summarizes a YouTube video, so enabling the YouTube integration
// here directly powers the next step. Wait on REDUX STATE (YouTube
// enabled), not a single click — if the user toggles off then back
// on, AC stays in sync.
// YouTube on the throughline; step 3 needs it. Waits on Redux state, not click, so toggling stays synced.
{ kind: 'move_to', target: S.actionsYoutubeToggle },
{ kind: 'popup', text: 'Flip YouTube on.' },
{
@@ -39,15 +31,12 @@ export const step02: OnboardingStep = {
},
timeoutMs: 90000,
},
// Expand the YouTube row to reveal its actions list.
{ kind: 'move_to', target: S.actionsYoutubeChevron },
{ kind: 'popup', text: 'Tap to peek inside.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.actionsYoutubeChevron },
},
// Hover the permission toggle for the first listed action and
// explain what it controls. No click required from the user.
{ kind: 'move_to', target: S.actionsPermissionToggle },
{
kind: 'popup',
@@ -2,13 +2,7 @@ import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyAgentLaunched, isYoutubeEnabled } from './skipPredicates';
// Primary demo: summarize a YouTube video — requires the YouTube
// transcript MCP, which step 2 enables. If a user reaches step 3 with
// YouTube not enabled (they skipped step 2's flow, dismissed it, or
// toggled YouTube back off), the agent would hang trying to call a
// missing MCP. The fallback prompt uses the agent's built-in web tools
// to do live research — same "agent does real work" demo, no MCP
// dependency.
// Primary: YouTube summary (needs MCP from step 2). Fallback uses built-in web tools (no MCP).
const YOUTUBE_PROMPT =
'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.';
const FALLBACK_PROMPT =
@@ -31,22 +25,13 @@ export const step03: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Chat input mounts asynchronously after + is clicked. waitForSelector
// inside the runtime handles the small delay before type_into runs.
{
kind: 'type_into',
target: S.chatInput,
// Anti-browser-agent directive on the YouTube path: the summary
// can be answered entirely from the youtube transcript MCP, and
// browser agents misbehave under load. The fallback path
// intentionally USES web tools — that's the whole point of the
// fallback (no MCP needed, agent still demonstrates real work).
// YouTube prompt bans browser agents (MCP handles it); fallback uses web tools by design.
text: (state) => (isYoutubeEnabled(state) ? YOUTUBE_PROMPT : FALLBACK_PROMPT),
speedMs: 12,
},
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
// the user lands on a typed-but-unsent prompt and has to hit send
// themselves, which is awkward and out-of-line with the other steps.
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
{
@@ -11,15 +11,8 @@ export const step04: OnboardingStep = {
'No more jumping between apps. You and your agents work in one place.',
videoSrc: './onboarding-videos/v2/04.mp4',
videoDurationLabel: '0:18',
// Auto-skip if the user already has a browser on canvas — re-running
// "open another browser" is just noise when they've clearly already
// discovered the feature.
// Auto-skip if a browser card already exists.
skipIf: hasAnyBrowserSpawned,
// Runtime auto-prepends a "click into a dashboard" hop when the user
// isn't already on a #/dashboards/:id route. No need to repeat that in
// ops — the previous version of this step pointed at the section
// header (which only toggles the sidebar list) and never actually
// navigated the user into a dashboard.
requiresDashboard: true,
ops: [
{ kind: 'move_to', target: S.browserButton },
@@ -18,32 +18,17 @@ export const step05: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Offset nudge: cursor SVG is asymmetric (tip top-left, body
// extends ~8px right and ~10px down). Default rect-center pinning
// puts the cursor BODY over the adjacent paperclip "Attach file"
// button instead of this icon. Shifting the tip up-and-left by
// (-10, -10) puts the body's visual center over this icon's
// center, where it belongs.
// Offset (-10,-10): cursor SVG is asymmetric so default-center pins on the adjacent paperclip.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to plug a browser into this chat.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
// Auto-fit the canvas before the drag-select demo so BOTH the new
// chat card AND the browser card are visible together. Without
// this, Dashboard's autoFocusSessionId pans the camera to center
// the freshly-created chat, which often clips the browser card half
// off-screen — and the user gets confused trying to drag-select
// something they can barely see. simulate:true clicks the
// fit-to-view toolbar button programmatically; user sees the
// camera resnap to a clean view in ~300ms before the drag demo.
// Fit-to-view so chat + browser card are both visible for drag-select; autoFocusSessionId otherwise clips.
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'click', target: S.canvasFitToView, simulate: true },
{ kind: 'delay', ms: 350 },
// AC demonstrates the drag-select on the browser card, then asks the
// user to do the same gesture for real (the actual product wires up
// the selection during a real mouse drag).
{ kind: 'drag_select', target: 'browser-card' },
{
kind: 'popup',
@@ -63,11 +48,7 @@ export const step05: OnboardingStep = {
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Quick canvas-controls tour, NOT a real step. The user now has a
// browser + chat on the canvas, which is the first time those
// toolbar buttons (fit-to-view, tidy, minimap) actually have
// anything meaningful to do. AC just hovers each one and drops a
// single short popup; no waits, no clicks expected from the user.
// Inline canvas-controls tour (hover + popup, no waits/clicks expected).
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'popup', text: 'Heads up! This snaps everything back into view.' },
{ kind: 'delay', ms: 1800 },
@@ -10,13 +10,7 @@ export const step06: OnboardingStep = {
videoSrc: './onboarding-videos/v2/06.mp4',
videoDurationLabel: '0:34',
requiresDashboard: true,
// Reuses the chat the user launched back in step 3 (the YouTube /
// web-research agent) as the "previous chat." Step 5's
// dependsOn-walk pattern would be appropriate here too, but
// pragmatically: by step 6 the user has already created at least one
// chat (step 3 marks itself done on chat:message_sent), so we just
// frame the existing chat as the helper instead of seeding a stub
// via seed-orchestration-demo.
// Reuses step 3's chat as the orchestratee; step 6 always has one available by now.
ops: [
{
kind: 'popup',
@@ -28,18 +22,14 @@ export const step06: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// See step05 — same nudge so the cursor's visual body center sits
// over the select-mode icon, not the adjacent paperclip.
// See step05 (cursor body offset).
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to hook in the older chat.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
// Same auto-fit as step 5: the new orchestrator chat triggers
// Dashboard's autoFocusSessionId, which often pushes the older
// chat off-screen. Click fit-to-view first so both cards are
// visible together for the drag-select demo.
// Fit-to-view (same reason as step 5).
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'click', target: S.canvasFitToView, simulate: true },
{ kind: 'delay', ms: 350 },
@@ -50,28 +40,21 @@ export const step06: OnboardingStep = {
},
{
kind: 'wait_user',
// Reuses agent:attached_to_browser; backend emits it for any element-selection attach.
condition: { kind: 'event_bus', event: 'agent:attached_to_browser' },
// Reuses the same attached event as step 5 for now — backend emits
// it for any element-selection attachment regardless of element type.
timeoutMs: 90000,
},
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
target: S.chatInput,
// Phrased to work against EITHER prompt step 3 sent — the
// YouTube summary OR the web-research fallback. "What it dug
// up" covers both without naming the source.
// Source-agnostic; works for either step 3 prompt.
text: 'Turn what it dug up into a PDF report and save it to my downloads.',
speedMs: 12,
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait for the user's message to actually go out — short wait, just
// to confirm the orchestration kicked off. Don't wait for the agent
// to fully finish: orchestrators legitimately run for minutes,
// sub-agents loop while doing real work, and trapping the user
// in step 6 until everything settles is the worst possible UX.
// Confirm message went out; don't wait for the orchestrator to finish (legitimately runs minutes).
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
@@ -10,10 +10,7 @@ export const step07: OnboardingStep = {
description: 'Teach agents how to handle specific tasks.',
videoSrc: './onboarding-videos/v2/07.mp4',
videoDurationLabel: '0:24',
// Narrowed from hasAnySkillInstalled → hasPdfSkillInstalled so a
// user who's installed any *other* skill still gets walked through
// the PDF-install demo (which is what the step's targets + popups
// are pointed at).
// Narrowed to PDF so other-skill users still walk through this demo.
skipIf: hasPdfSkillInstalled,
ops: [
{ kind: 'move_to', target: S.sidebarSkills },
@@ -22,29 +22,7 @@ export const step08: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.appsNewButton },
},
// After clicking +, the /apps/new route mounts ViewEditor which
// asynchronously renders AgentChat in the left pane. Three failure
// modes we have to defend against:
// 1. Cold start can take well over 8 s before AgentChat mounts
// inside the app-builder scope wrapper — vite warm-up + session
// creation + three parallel onboarding sessions racing the
// backend's probe-model queue stack up under load.
// 2. The /apps/new route briefly mounts → unmounts → remounts
// ViewEditor (runtime/start → runtime/stop → runtime/start
// visible in the dev log when the React Strict-Mode double-
// effect collides with the route transition). The scope
// wrapper disappears during the unmount, and wait_for_dom
// polling can land in that gap.
// 3. AgentChat's hardcoded `disabled={false}` means the
// contenteditable attribute is always "true" when the input
// mounts — so we don't need to gate on it (and gating on a
// stringly-serialized React attribute introduces a brittle
// dependency on React's attribute reflection).
//
// Fix: wait for the SCOPED chat-input. 30 s timeout swallows any
// reasonable cold start including the mount-unmount-remount cycle.
// An extra 350 ms `delay` lets the post-mount React commit settle
// (refs, event handlers, focus shims) before we move the cursor.
// Wait for the SCOPED chat-input; survives cold-starts and the StrictMode mount/unmount/remount cycle.
{
kind: 'popup',
text: 'Loading the App Builder...',
@@ -55,9 +33,6 @@ export const step08: OnboardingStep = {
timeoutMs: 60000,
},
{ kind: 'delay', ms: 350 },
// The App Builder chat lives in the left pane on /apps/new — the
// chat-input selector resolves to it via the App Builder scope
// priority in resolveSelector.
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
@@ -65,20 +40,11 @@ export const step08: OnboardingStep = {
text: 'Make me a pdf previewer app',
speedMs: 12,
},
// AC auto-clicks send per spec ("the AC should auto send this").
// Tiny pause first to let onInput's draft-state commit land — the
// send button is disabled-while-empty, so clicking before React's
// next commit sometimes lands on the stale-disabled button.
// 120ms pause lets onInput's draft-state commit before clicking; send-button is disabled-while-empty.
{ kind: 'delay', ms: 120 },
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait only for chat:message_sent (the prompt actually going out).
// Don't wait for app:generation_done — the App Builder agent can
// take any of several legitimate paths: save as a standalone HTML
// to ~/Downloads and open in the system browser, save as an
// OpenSwarm Output, or skip saving entirely. We can't reliably
// detect every completion shape, and trapping the user in step 8
// until a specific one happens is the worst possible UX.
// chat:message_sent only; app:generation_done has too many legitimate completion shapes.
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
@@ -1,20 +1,14 @@
// Onboarding v2 step / op / advance-condition schema.
//
// Steps are pure data: a sequence of ACOps (cursor primitives) interleaved
// with wait_user gates that block until an AdvanceCondition fires. The
// runtime in ../ac/acRuntime.ts is the only place that knows how to
// execute these; step files import only this module.
// Onboarding-v2 step/op/advance-condition schema; ../ac/acRuntime.ts is the only executor.
import type { RootState } from '@/shared/state/store';
export type Selector = string; // matches data-onboarding="<v>" or data-select-type="<v>"
/** Matches data-onboarding="<v>" or data-select-type="<v>". */
export type Selector = string;
export type ACMultiChoiceOption = {
id: string;
label: string;
// Optional branching — if present, picking this option queues additional
// ops to run before the rest of the step's ops continue. Lets one step
// diverge based on user choice without splitting into N steps.
/** If set, queue extra ops on selection so one step can branch without splitting. */
thenOps?: ACOp[];
};
@@ -26,10 +20,7 @@ export type ACOp =
| {
kind: 'type_into';
target: Selector;
// String for static text; function for runtime branching (e.g. step
// 3 picks YouTube prompt if isYoutubeEnabled, else a web-research
// fallback). Evaluated once at op-execution time against current
// Redux state — not reactive to subsequent state changes.
/** Static string or function evaluated once at op-execution; not reactive to subsequent state. */
text: string | ((state: RootState) => string);
speedMs?: number;
}
@@ -37,11 +28,7 @@ export type ACOp =
| { kind: 'drag_select'; target: Selector }
| { kind: 'wait_user'; condition: AdvanceCondition; hint?: string; timeoutMs?: number }
| { kind: 'delay'; ms: number }
// Poll a raw CSS selector (not a data-onboarding shorthand) until it
// appears in the DOM, up to `timeoutMs`. Used by step 8 to wait for
// the App Builder's scoped chat-input to mount before typing into it
// (previously a fixed 1500ms delay that under-fit slow cold-starts
// and over-fit warm ones).
/** Poll a raw CSS selector until it mounts, up to timeoutMs (step 8 uses for App Builder chat-input). */
| { kind: 'wait_for_dom'; css: string; timeoutMs?: number }
| { kind: 'outro' };
@@ -60,22 +47,18 @@ export interface StepDependency {
export interface OnboardingStep {
id: string;
stage: StepStage;
index: number; // 1..N (currently 1..8)
/** 1..N (currently 1..8). */
index: number;
title: string;
description: string;
videoSrc?: string;
videoDurationLabel?: string; // e.g. "0:24" — shown in the panel preview chip
/** Shown in the panel preview chip, e.g. "0:24". */
videoDurationLabel?: string;
ops: ACOp[];
dependsOn?: StepDependency[];
// skipIf is evaluated on launch (and on each Show me click) to mark a step
// already-done without running its flow. Lets existing v1.0.29 users
// upgrade and have already-completed milestones pre-checked.
/** Mark a step already-done at launch / Show me click without running its flow. */
skipIf?: (state: RootState) => boolean;
// True if the step's ops target dashboard-toolbar elements (+, browser,
// chat input, send, element-selection toggle, apps button). The runtime
// auto-prepends a "click into a dashboard" hop when the user isn't
// already on a #/dashboards/:id route. Without this, every "Show me"
// from the actions/skills/apps pages would hang on a missing target.
/** True when ops target dashboard-toolbar elements; runtime auto-prepends a click-into-dashboard hop. */
requiresDashboard?: boolean;
}
@@ -1,12 +1,4 @@
// Onboarding v2 telemetry — wraps the existing report() surface so all
// events land under surface='onboarding_v2' (separate from the legacy
// onboarding/walkthrough rows so dashboards stay clean during transition).
//
// Standard properties on every report:
// step_id — current step (or 'panel' / 'roadmap' for non-step events)
// stage — 'get_started' | 'customize'
// ms_since_step — time since the active step started (panel "Show me" click)
// Plus whatever the caller passes in.
// Wraps report() so all onboarding-v2 events land under surface='onboarding_v2'.
import { report as _report } from '@/shared/serviceClient';
+3 -25
View File
@@ -1,25 +1,8 @@
// Bayer-dithering pixel-blast background. Same shader as the inline
// splash in the webapp_template's index.html and the React component at
// `webapp_template/frontend/src/components/PixelBlast.tsx`, so all three
// "cold start" phases of an App preview look identical:
//
// 1. Desktop placeholder before Vite has bound (`<InstallPlaceholder>`
// in ViewEditor, before frontend_url arrives over the runtime WS).
// 2. Inline `<canvas>` in `index.html`, painted before any JS bundle
// loads.
// 3. React-rendered placeholder in `pages/index.tsx`, replaced when
// the agent overwrites that file.
//
// Plain WebGL2, no three.js / postprocessing.
// Bayer-dithering pixel-blast background; same shader as webapp_template splash so cold-start phases match.
import React, { useEffect, useRef } from 'react';
// Module-level epoch so a fresh component mount picks up where the
// previous mount left off in the noise field. Without this, every time
// the user clicked away from a dashboard card and back the animation
// reset to t=0, which read as a jarring "snap" instead of an ambient
// loop. Captured once at module load; all instances of the component
// share it.
// Module-level epoch so remounts pick up where the previous mount left off in the noise loop.
const PIXEL_BLAST_EPOCH = performance.now();
interface PixelBlastProps {
@@ -85,12 +68,7 @@ float fbm2(vec2 uv, float t){
return sum * 0.5 + 0.5;
}
void main(){
// Offset by a non-zero constant so y=0 and x=0 don't land on FBM
// singularities. Without this the noise function returns the same
// value along the screen center axes, which the Bayer threshold
// accents into a visible horizontal (or vertical) bright stripe.
// 137.5 is the golden-ratio angle in degrees, a classic
// "no-aliasing" constant for shader UVs.
// 137.5 (golden-ratio angle) offsets off the FBM singularities so the center axes don't bright-stripe.
vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5 + vec2(137.5, 137.5);
float aspectRatio = uResolution.x / uResolution.y;
float cellPixelSize = 8.0 * uPixelSize;
+10 -26
View File
@@ -15,15 +15,14 @@ import {
CheckoutSource,
} from '@/shared/subscription/checkout';
// Pricing table. Keep in sync with the Stripe price IDs configured on
// api.openswarm.com. Annual is shown as the monthly-equivalent rate with a
// "billed annually" subtitle, mirroring Anthropic's pricing page copy.
// Pricing table; keep in sync with Stripe price IDs on api.openswarm.com.
interface PlanDef {
id: OpenSwarmPlan;
name: string;
tagline: string;
monthly: number;
annual: number; // billed monthly equivalent when paid annually
/** Monthly-equivalent when billed annually. */
annual: number;
featuresHeader: string;
features: string[];
recommended?: boolean;
@@ -78,15 +77,11 @@ interface PlanPickerProps {
defaultPlan?: OpenSwarmPlan;
defaultInterval?: BillingInterval;
compact?: boolean;
// The user's current or most-recent tier, if any. Drives the CTA text on
// each card: same-tier → "Resubscribe", higher-tier → "Upgrade",
// lower-tier → "Downgrade". When undefined the user is a new customer and
// every card says "Subscribe".
/** User's current tier; drives Resubscribe/Upgrade/Downgrade CTA copy. */
currentPlan?: OpenSwarmPlan;
onSubscribed?: (plan: OpenSwarmPlan) => void;
}
// Tier ordering for upgrade/downgrade comparison.
const TIER_RANK: Record<OpenSwarmPlan, number> = {
pro: 1,
pro_plus: 2,
@@ -133,15 +128,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
report('subscription', 'billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
// still keeping the same visual hierarchy (plan name ≈ price size).
// Typography scale: smaller in compact mode (modal), same hierarchy.
const sz = compact
? { name: '1.35rem', price: '2rem', tagline: '0.78rem', features: '0.78rem', cta: '0.82rem', micro: '0.7rem', sub: '0.68rem', hdr: '0.72rem', suffix: '0.78rem' }
: { name: '1.75rem', price: '2.4rem', tagline: '0.85rem', features: '0.85rem', cta: '0.88rem', micro: '0.72rem', sub: '0.72rem', hdr: '0.78rem', suffix: '0.85rem' };
return (
<Box sx={{ width: '100%' }}>
{/* Billing interval toggle — annual selected by default */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: compact ? 2 : 2.5 }}>
<ToggleButtonGroup
value={interval}
@@ -167,11 +160,10 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
}}
>
<ToggleButton value="monthly">Monthly</ToggleButton>
<ToggleButton value="annual">Annual · save 15%</ToggleButton>
<ToggleButton value="annual">Annual, save 15%</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Plan cards — grid in regular mode, stacked column in compact */}
<Box
sx={{
display: 'grid',
@@ -200,14 +192,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
transition: 'border-color 0.15s, background 0.15s',
}}
>
{/* Name + "your plan" indicator */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.8, mb: 0.4 }}>
<Typography sx={{ fontSize: sz.name, fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>
{plan.name}
</Typography>
{isDefault && (
<Typography sx={{ fontSize: sz.micro, color: c.text.muted, fontWeight: 500 }}>
· your plan
your plan
</Typography>
)}
</Box>
@@ -216,7 +207,6 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{plan.tagline}
</Typography>
{/* Price row — big number + /mo */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.2 }}>
<Typography sx={{ fontSize: sz.price, fontWeight: 700, color: c.text.primary, lineHeight: 1 }}>
${price}
@@ -229,9 +219,6 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{interval === 'annual' ? 'billed annually' : 'billed monthly'}
</Typography>
{/* CTA moved ABOVE features Anthropic pattern. Filled accent
for the recommended tier, outlined for the others; no
separate RECOMMENDED badge needed. */}
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={pending !== null}
@@ -251,15 +238,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{isPending ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.7 }}>
<CircularProgress size={14} sx={{ color: 'inherit' }} />
<span>Opening</span>
<span>Opening...</span>
</Box>
) : (
ctaLabel(plan.id, plan.name, currentPlan)
)}
</Button>
{/* Microcopy row under every CTA matches Anthropic's
reassurance-under-the-big-button pattern. */}
<Typography
sx={{
fontSize: sz.micro,
@@ -270,13 +255,12 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
}}
>
{isRecommended
? 'Most popular · cancel anytime'
? 'Most popular, cancel anytime'
: plan.id === 'ultra'
? 'No commitment · cancel anytime'
? 'No commitment, cancel anytime'
: 'Cancel anytime'}
</Typography>
{/* Divider + cumulative features — "Everything in Pro, plus:" */}
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
@@ -66,7 +66,7 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
const isLabelFloating = focused || hasContent;
// Sync external value editor on mount / when value changes externally
// Sync external value to editor on mount / when value changes externally.
const lastEmittedRef = useRef<string | null>(null);
useEffect(() => {
const editor = editorRef.current;
+4 -30
View File
@@ -1,26 +1,4 @@
// Mandatory sign-in gate. Two paths to identity:
//
// 1. Continue with Google → cloud OAuth handoff (existing).
// Opens https://api.openswarm.com/api/auth/google/start in the OS
// browser; the cloud's bearer-handoff page POSTs the bearer back to
// this desktop's local /api/auth/signin-activate. settings.user_id
// flips non-null and the gate self-dismisses (SignInGateLoader's
// poll picks up the change within ~2s).
//
// 2. Email magic link. Two-stage:
// - Stage 1: user enters their email, we POST /api/auth/email/start.
// Cloud mints a 6-digit code, stores its hash, sends it via Resend.
// - Stage 2: user pastes the code, we POST /api/auth/email/verify.
// On success the cloud upserts the users row, mints a bearer with
// source='email', returns the same handoff shape as Google, and
// the desktop's existing signin-activate path takes it from there.
//
// No password. Each sign-in (first or returning) requires reading a fresh
// code from the inbox. Slightly more friction than a stored-password fast
// path, but it eliminates the "someone with just my email signs in as me"
// worry and means there's no credential to store, leak, or rotate.
//
// No "Skip for now" — sign-in is mandatory.
// Mandatory sign-in gate; Google OAuth handoff or email magic-link (6-digit code per sign-in).
import React, { useState } from 'react';
import {
@@ -82,9 +60,7 @@ export default function SignInGate(): JSX.Element {
}
setBusy(true);
// "Failed to fetch" or 404 here means the cloud build doesn't have the
// magic-link routes yet. Surface a single friendly hint instead of the
// raw network error.
// 404/"Failed to fetch" = cloud build lacks magic-link routes; surface a friendly hint.
const EMAIL_UNAVAILABLE_MSG =
"Email sign-in isn't available on this build yet. Please use Continue with Google for now, or update OpenSwarm.";
@@ -145,8 +121,7 @@ export default function SignInGate(): JSX.Element {
}
const data = (await res.json()) as { bearer?: string; user_id?: string; user_email?: string };
if (!data.bearer) throw new Error('Server did not return a bearer.');
// Hand the bearer to the local backend the same way Google's
// handoff page does, so the rest of the app converges identically.
// Hand bearer to local backend like Google's handoff page so the app converges identically.
const activate = await fetch(`${API_BASE}/auth/signin-activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -160,8 +135,7 @@ export default function SignInGate(): JSX.Element {
const text = await activate.text().catch(() => '');
throw new Error(text || `Local activate failed (${activate.status})`);
}
// SignInGateLoader's polling picks up the new user_id within 2s
// and unmounts this gate. Nothing else to do.
// SignInGateLoader's 2s poll picks up new user_id and unmounts the gate.
} catch (err) {
setErrMsg((err as Error).message || 'Verification failed.');
} finally {
@@ -139,7 +139,6 @@ export function useDomElementSelector(): DomSelectorState {
}, [ctx?.selectedElements]);
const handleMouseMove = useCallback((e: MouseEvent) => {
// If we're drawing a drag rectangle, update it instead of hover overlay
if (dragOriginRef.current) {
const origin = dragOriginRef.current;
const dx = e.clientX - origin.x;
@@ -253,15 +252,7 @@ export function useDomElementSelector(): DomSelectorState {
preDragFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
// Make webviews/iframes transparent to mouse events for the duration of
// a potential drag. Without this, dragging the selection rect across a
// browser card freezes the rect at the webview's entry edge — the
// <webview> hit-tests the cursor at the OS level and steals mousemove
// events from the document listener until the cursor exits the other
// side. Reuses the existing CSS rule installed by useDashboardSelection
// ("body.dashboard-marquee-active webview, ... { pointer-events: none }").
// Added on mousedown (not on first drag-threshold cross) so the cursor
// is already passing through if the drag begins inside a webview.
// Make webviews pointer-transparent during drag; the OS-level hit-test would otherwise steal mousemove.
document.body.classList.add('dashboard-marquee-active');
}, []);
@@ -375,8 +366,7 @@ export function useDomElementSelector(): DomSelectorState {
dragBoundsRef.current = null;
isDraggingRef.current = false;
preDragFocusRef.current = null;
// Defensive if select mode flips off mid-drag, drop the class so
// webviews regain interactivity.
// Defensive: if select mode flips off mid-drag, drop the class so webviews regain interactivity.
document.body.classList.remove('dashboard-marquee-active');
};
}, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]);
@@ -26,10 +26,6 @@ import { useAppSelector } from '@/shared/hooks';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ---------------------------------------------------------------------------
// Integration metadata (icons, colors) for known MCP servers
// ---------------------------------------------------------------------------
interface IntegrationMeta {
label: string;
color: string;
@@ -57,10 +53,6 @@ const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
};
// ---------------------------------------------------------------------------
// MCP tool name parser
// ---------------------------------------------------------------------------
export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
@@ -85,10 +77,6 @@ function sanitizeServerName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
// ---------------------------------------------------------------------------
// Look up MCP tool metadata from the Redux tools store
// ---------------------------------------------------------------------------
interface McpToolMeta {
integration: IntegrationMeta | null;
description: string;
@@ -119,10 +107,6 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
}, [parsed, toolItems]);
}
// ---------------------------------------------------------------------------
// Smart input summary for MCP tools
// ---------------------------------------------------------------------------
function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
const lower = actionName.toLowerCase();
@@ -131,7 +115,7 @@ function getMcpInputSummary(actionName: string, toolInput: Record<string, any>):
const to = toolInput.to || toolInput.recipient || '';
const subject = toolInput.subject || '';
if (query) return `Search: "${query}"`;
if (to && subject) return `To ${to} ${subject}`;
if (to && subject) return `To ${to}: ${subject}`;
if (to) return `To ${to}`;
if (subject) return `Subject: ${subject}`;
}
@@ -139,7 +123,7 @@ function getMcpInputSummary(actionName: string, toolInput: Record<string, any>):
if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) {
const summary = toolInput.summary || toolInput.title || toolInput.event_name || '';
const start = toolInput.start || toolInput.start_time || toolInput.date || '';
if (summary && start) return `${summary} ${start}`;
if (summary && start) return `${summary}: ${start}`;
if (summary) return summary;
if (start) return `Date: ${start}`;
}
@@ -177,10 +161,6 @@ function getMcpInputSummary(actionName: string, toolInput: Record<string, any>):
return '';
}
// ---------------------------------------------------------------------------
// Shared components
// ---------------------------------------------------------------------------
interface Props {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => void;
@@ -313,10 +293,6 @@ const ToolPreview: React.FC<ToolPreviewProps> = ({ request, tokens: c }) => {
}
};
// ---------------------------------------------------------------------------
// QuestionForm (AskUserQuestion — unchanged)
// ---------------------------------------------------------------------------
function getOptionKey(opt: any): string {
return opt.id || opt.value || opt.label || opt.text || String(opt);
}
@@ -583,10 +559,6 @@ export const QuestionForm: React.FC<QuestionFormProps> = ({ request, onApprove,
);
};
// ---------------------------------------------------------------------------
// GenericApprovalBar — redesigned for MCP tools
// ---------------------------------------------------------------------------
const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) => {
const c = useClaudeTokens();
const [denyMessage, setDenyMessage] = useState('');
@@ -755,7 +727,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
overflow: 'hidden',
}}
>
{/* Header row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, pt: 1.75, pb: 0.5 }}>
<Box
sx={{
@@ -809,7 +780,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
</Box>
</Box>
{/* Input summary / details */}
<Box sx={{ px: 2, pt: 1, pb: 0.5 }}>
{summary && (
<Box
@@ -850,7 +820,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
</Collapse>
</Box>
{/* Deny reason input */}
{showDenyInput && (
<Box sx={{ px: 2, pb: 0.5 }}>
<TextField
@@ -872,7 +841,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
</Box>
)}
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, px: 2, pt: 1, pb: 1.75 }}>
<Button
variant="contained"
@@ -930,10 +898,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
);
};
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
const ApprovalBar: React.FC<Props> = (props) => {
if (props.request.tool_name === 'AskUserQuestion') {
return <QuestionForm request={props.request} onApprove={props.onApprove} onDeny={props.onDeny} />;
@@ -941,10 +905,6 @@ const ApprovalBar: React.FC<Props> = (props) => {
return <GenericApprovalBar {...props} />;
};
// ---------------------------------------------------------------------------
// BatchApprovalBar — grouped mass approve/deny when many approvals pending
// ---------------------------------------------------------------------------
interface ToolGroup {
toolName: string;
parsed: ParsedTool;
@@ -1014,7 +974,6 @@ export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, on
overflow: 'hidden',
}}
>
{/* Global actions bar */}
<Box
sx={{
display: 'flex',
@@ -1068,7 +1027,6 @@ export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, on
</Button>
</Box>
{/* Per-group rows */}
{groups.map((group) => (
<GroupRow
key={group.toolName}
@@ -1091,10 +1049,6 @@ export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, on
);
};
// ---------------------------------------------------------------------------
// GroupRow — a single tool-name group within the batch bar
// ---------------------------------------------------------------------------
interface GroupRowProps {
group: ToolGroup;
expanded: boolean;
@@ -152,9 +152,7 @@ const lightFeedColors: FeedColors = {
scrollThumb: '#ccc9c0',
};
// Stable empty-object reference for the streaming selector to return
// when there are no browser sessions yet; keeps shallowEqual happy
// across renders so we don't churn on an "empty" dict literal.
// Stable ref keeps shallowEqual happy when there are no browser sessions yet.
const EMPTY_STREAMING: Record<string, StreamingMessage> = Object.freeze({}) as Record<string, StreamingMessage>;
const selectBrowserSessions = createSelector(
@@ -181,12 +179,7 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
// Subscribe to only the streaming entries that belong to THIS feed's
// browser sessions. Previously this read the full bySession dict,
// which re-rendered the feed on every streamed character from every
// agent on the dashboard, which was the "glitching when agent is
// using the browser" experience. With shallowEqual we only re-render
// when one of our specific browser sessions actually gets a delta.
// Subscribe only to this feed's sessions; reading the full streaming dict re-renders on every char from every agent.
const browserSessionIds = useMemo(
() => browserSessions.map((s) => s.id).sort().join(','),
[browserSessions],
@@ -233,15 +226,11 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
0,
);
// Sticky-to-bottom: auto-scroll to the latest content unless the user
// has manually scrolled up. Re-enable auto-scroll when the user scrolls
// back to the bottom (within a small threshold).
const isStuckToBottom = useRef(true);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
// "At bottom" = within 30px of the bottom edge
isStuckToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
}, []);
@@ -261,15 +250,11 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
ref={scrollRef}
onScroll={handleScroll}
onWheel={(e) => {
// Capture wheel events so the feed scrolls on hover without
// needing to click/focus first. Without this, the parent chat
// scroll container eats the wheel events.
// Block wheel only while feed can still scroll; at boundaries let parent chat take over.
const el = scrollRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1 && e.deltaY > 0;
// Only stop propagation when the feed has room to scroll in this
// direction. At boundaries, let the parent scroll naturally.
if (!atTop && !atBottom) e.stopPropagation();
}}
sx={{
@@ -327,10 +312,6 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
<EntryRow key={i} entry={entry} accentColor={accentColor} fc={fc} />
))}
{/* Inline RequestHumanIntervention matches the DynamicIsland
and BrowserAgentOverlay style (amber, hand icon, compact pill).
Same request_id whichever surface the user responds from
first resolves the approval; the others auto-dismiss. */}
{session.pending_approvals?.filter(
(a) => a.tool_name === 'RequestHumanIntervention',
).map((intervention) => {
@@ -364,7 +345,7 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
>
{problem}
</Typography>
<Tooltip title="Done continue" arrow>
<Tooltip title="Done, continue" arrow>
<IconButton
size="small"
onClick={() => dispatch(handleApproval({ requestId: intervention.id, behavior: 'allow' }))}
+33 -151
View File
@@ -40,11 +40,7 @@ import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard';
import { getWebview } from '@/shared/browserRegistry';
import { API_BASE, getAuthToken } from '@/shared/config';
// Slash command parser (Phase 2). Returns true if the command was handled
// and the prompt should NOT be sent to the agent. Three commands:
// /context — toggle a drawer (purely UI, dispatched via window event)
// /compact — POST /sessions/{id}/compact, force compaction now
// /clear — POST /sessions/{id}/clear, reset SDK session id (UI history kept)
/** Handles /context, /compact, /clear; returns true if intercepted so the prompt isn't sent to the agent. */
async function handleSlashCommand(cmd: string, sessionId: string): Promise<boolean> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
@@ -53,20 +49,17 @@ async function handleSlashCommand(cmd: string, sessionId: string): Promise<boole
window.dispatchEvent(new CustomEvent('openswarm:context-drawer', { detail: { sessionId, open: true } }));
return true;
}
// Note: API_BASE already ends in `/api`, so don't double it up — the
// /compact and /clear handlers in main.py are mounted at the absolute
// path `/api/agents/sessions/{id}/compact` (not under the `agents`
// SubApp), so the request URL is `${API_BASE}/agents/...`.
// /compact and /clear mount at /api/agents/sessions/{id}/..., not under the agents SubApp.
if (cmd === '/compact') {
try {
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
} catch { /* errors flow through context_status WS event */ }
} catch {}
return true;
}
if (cmd === '/clear') {
try {
await fetch(`${API_BASE}/agents/sessions/${sessionId}/clear`, { method: 'POST', headers });
} catch { /* same */ }
} catch {}
return true;
}
return false;
@@ -90,11 +83,7 @@ export interface AttachedImage {
data: string;
media_type: string;
preview: string;
// Set by addImageFiles when we use URL.createObjectURL for the preview
// instead of a data URL. handleSend reads this with FileReader at
// send time so we don't carry the full base64 in memory between
// attach and send. Falls back to base64 conversion of the data URL
// if the file is missing (e.g. paste-from-clipboard with raw base64).
// Set when preview uses createObjectURL; handleSend reads via FileReader to avoid retaining base64 in memory.
_file?: File;
}
@@ -133,13 +122,9 @@ export interface ChatInputHandle {
setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void;
}
// Module-level draft store — survives component unmount/remount. Keyed by
// sessionId (or a fallback owner id). Stores the raw innerHTML of the
// contentEditable div so skill pills, formatting, etc. are preserved.
// Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML.
const _draftStore = new Map<string, string>();
// Debounce per-owner so we don't read innerHTML (full DOM serialization)
// on every keystroke. ~200ms is below human "I expected my draft saved"
// while still coalescing fast typing and giant pastes into one read.
// 200ms debounce coalesces fast typing; innerHTML reads do full DOM serialization.
const _draftDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const DRAFT_DEBOUNCE_MS = 200;
function scheduleDraftSave(ownerId: string, getHtml: () => string) {
@@ -203,9 +188,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
// Brand colors for provider headers in the model picker — these match
// the SubscriptionCard colors in Settings and help users distinguish
// groups at a glance.
// Mirrors SubscriptionCard colors in Settings.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
openai: '#74AA9C',
@@ -217,7 +200,7 @@ const PROVIDER_COLORS: Record<string, string> = {
mistral: '#FF7000',
qwen: '#A974FF',
cohere: '#FF7759',
openrouter: '#64748B', // OR brand is muted slate, not the bright teal we had.
openrouter: '#64748B',
};
const LS_RECENT_MODELS = 'openswarm.picker.recentModels';
@@ -236,12 +219,10 @@ function readLS<T>(key: string, fallback: T): T {
}
function writeLS(key: string, value: unknown) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* quota / private mode */ }
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
// Heuristic 1-5 fallback for entries without a backend tier (only kicks in
// for the FALLBACK_MODELS pre-load list). Cost buckets: <$0.50/1, <$2/2,
// <$7/3, <$25/4, ≥$25/5.
// Heuristic tiering for pre-load FALLBACK_MODELS only; backend provides real tiers post-load.
type Tier = 1 | 2 | 3 | 4 | 5;
const clampTier = (n: number): Tier => Math.max(1, Math.min(5, n)) as Tier;
@@ -272,8 +253,7 @@ function tierCost(opt: any): Tier {
return _costBucket(opt.output_cost_per_1m ?? 0);
}
// Pull a version number from a label. Skips param counts (70B/120B/etc)
// by clamping to <30. "Claude Opus 4.7" → 4.7, "GPT-5.5" → 5.5.
/** Extract version number from a model label; clamps to <30 to skip param counts like 70B/120B. */
function modelVersion(label: string): number {
const matches = String(label).matchAll(/(\d+(?:\.\d+)?)/g);
let bestVersion = 0;
@@ -284,7 +264,7 @@ function modelVersion(label: string): number {
return bestVersion;
}
// Strip versions + route suffixes so "Claude Sonnet 4.6" and 4.5 share a key.
/** Strip versions and route suffixes so "Claude Sonnet 4.6" and 4.5 share one key. */
function modelFamilyKey(label: string): string {
return String(label)
.toLowerCase()
@@ -294,7 +274,7 @@ function modelFamilyKey(label: string): string {
.trim();
}
// Sort: intelligence desc, family asc, version desc, label asc.
/** Sort: intelligence desc, family asc, version desc, label asc. */
function sortModelsForPicker<T extends { label: string }>(models: T[]): T[] {
const intelOf = (opt: any): number => {
if (Array.isArray(opt.tiers) && opt.tiers.length === 3) return opt.tiers[0];
@@ -329,13 +309,11 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (autoFocus) editorRef.current?.focus();
}, [autoFocus]);
// Restore draft from the module-level store on mount.
useEffect(() => {
const saved = _draftStore.get(ownerId);
const editor = editorRef.current;
if (saved && editor && !editor.textContent?.trim()) {
editor.innerHTML = saved;
// Move cursor to end
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
@@ -343,7 +321,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
sel?.removeAllRanges();
sel?.addRange(range);
}
// Only on mount — ownerId is stable for the component's lifetime
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -362,11 +339,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const toolItems = useAppSelector((state) => state.tools.items);
// Build flat model list with provider grouping. Group names come from the
// backend's /agents/models response verbatim — "OpenSwarm Pro" for
// proxy-routed Claude, "Anthropic" for direct/subscription-routed Claude,
// plus the non-Anthropic providers. Only the pre-load fallback still needs
// to pick a label since no models have been fetched yet.
const allModelOptions = useMemo(() => {
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic';
@@ -402,8 +374,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const [modelSearch, setModelSearch] = useState('');
const modelSearchRef = useRef<HTMLInputElement | null>(null);
// Recents + history persisted to localStorage. Trim on read so existing
// entries from when MAX was higher respect the current cap.
const [recentModels, setRecentModels] = useState<string[]>(
() => readLS<string[]>(LS_RECENT_MODELS, []).slice(0, RECENT_MODELS_MAX),
);
@@ -430,7 +400,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
reasoning: false, subscription: false, apiKey: false,
});
// Indexed sliders (idx 0 = Any).
const CTX_STEPS = [0, 32_000, 128_000, 200_000, 500_000, 1_000_000];
const CTX_LABELS = ['Any', '32K+', '128K+', '200K+', '500K+', '1M+'];
const COST_STEPS = [Infinity, 50, 15, 5, 1, 0];
@@ -465,11 +434,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
});
}, []);
// Probe is keyed by model value so stale results don't display.
// Keyed by model value so stale probe results don't display.
const [probeResult, setProbeResult] = useState<{ value: string; ok: boolean; error?: string; latency_ms?: number } | null>(null);
// Search + capability filters + sliders. Empty/all-default returns the
// grouping unchanged so first-open is fast.
const filteredModelGroups = useMemo(() => {
const q = modelSearch.trim().toLowerCase();
const minCtx = CTX_STEPS[ctxIdx] || 0;
@@ -480,16 +447,15 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
);
const filterFn = (m: any): boolean => {
if (capFilters.reasoning && !m.reasoning) return false;
// Subscription / API key chips are OR'd.
if (capFilters.subscription || capFilters.apiKey) {
const okSub = capFilters.subscription && m.billing_kind === 'subscription';
const okApi = capFilters.apiKey && m.billing_kind === 'api_key';
if (!okSub && !okApi) return false;
}
if (minCtx > 0 && (m.context_window ?? 0) < minCtx) return false;
// maxCost=0 means "Free only" subscription passes (free to user),
// paid/api_key excluded regardless of $.
// maxCost=0 ("Free only") passes subscription (free to user); paid/api_key excluded regardless of price.
if (maxCost !== Infinity) {
if (maxCost === 0) {
if (m.billing_kind !== 'free' && m.billing_kind !== 'subscription') return false;
} else {
@@ -516,8 +482,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
return out;
}, [modelSearch, allModelOptions.grouped, capFilters, ctxIdx, costIdx]);
// Footer summary — counts reflect what the user actually has access to
// right now (post-filter, post-credentials).
const pickerSummary = useMemo(() => {
let total = 0, free = 0, reasoning = 0, subscription = 0, apiKey = 0, paid = 0, longContext = 0;
for (const ms of Object.values(filteredModelGroups)) {
@@ -534,7 +498,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
return { total, free, reasoning, subscription, apiKey, paid, longContext };
}, [filteredModelGroups]);
// Recents materialised against current catalog so removed models drop out.
const recentMaterialised = useMemo(() => {
const flatByValue = new Map(allModelOptions.flat.map((m) => [m.value, m]));
return recentModels
@@ -548,14 +511,11 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
&& recentMaterialised.length > 0
);
// Shared tooltip body for Recents + groups. Tiers come from backend; the
// local heuristic only kicks in for the pre-load FALLBACK_MODELS list.
const buildModelTooltip = useCallback((opt: any): React.ReactNode => {
const [intel, speed, cost] = (Array.isArray(opt.tiers) && opt.tiers.length === 3)
? opt.tiers
: [tierIntelligence(opt), tierSpeed(opt), tierCost(opt)];
const billingKind: 'paid' | 'subscription' | 'free' = opt.billing_kind || (opt.is_free ? 'free' : 'paid');
// 15 pixel cells, gradient palette per tier — matches Settings' PixelBarOuter.
const Bars = ({ filled, palette }: { filled: number; palette: string[] }) => {
const TOTAL_CELLS = 15;
const filledCells = Math.round((filled / 5) * TOTAL_CELLS);
@@ -650,7 +610,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
);
}, [c]);
// Match Settings stat-card surface (Settings.tsx cardSx).
const tooltipSlotProps = useMemo(() => ({
tooltip: {
sx: {
@@ -669,15 +628,13 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}), [c]);
const [images, setImages] = useState<AttachedImage[]>([]);
// Track current images via ref so the unmount cleanup sees the latest
// list (not the empty-array snapshot from the effect's first run) and
// can revoke any outstanding blob: preview URLs.
// Ref so unmount cleanup revokes the latest blob: preview URLs.
const imagesRef = useRef(images);
imagesRef.current = images;
useEffect(() => () => {
for (const img of imagesRef.current) {
if (img.preview?.startsWith('blob:')) {
try { URL.revokeObjectURL(img.preview); } catch { /* nothing */ }
try { URL.revokeObjectURL(img.preview); } catch {}
}
}
}, []);
@@ -709,7 +666,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const [modelAnchor, setModelAnchor] = useState<HTMLElement | null>(null);
const [thinkingAnchor, setThinkingAnchor] = useState<HTMLElement | null>(null);
// Auto-focus search on menu open; reset search on close.
useEffect(() => {
if (modelAnchor) {
const t = setTimeout(() => modelSearchRef.current?.focus(), 30);
@@ -718,7 +674,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
setModelSearch('');
}, [modelAnchor]);
// Debounced 1-token probe to surface 401/402/etc before send.
// Debounced 1-token probe surfaces 401/402/etc before send.
useEffect(() => {
if (!model) return;
let cancelled = false;
@@ -732,9 +688,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (cancelled) return;
const data = await res.json();
setProbeResult({ value: model, ok: !!data.ok, error: data.error, latency_ms: data.latency_ms });
} catch {
// Non-blocking — chat send surfaces any real error.
}
} catch {}
}, 350);
return () => {
cancelled = true;
@@ -791,12 +745,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}, []);
const addImageFiles = useCallback((files: FileList | File[]) => {
// Two-stage: thumbnail / lightbox preview comes from a blob: URL
// (kept by the browser as a binary file handle, not a JS string),
// and the base64 only materializes asynchronously for the actual
// send payload. Holding only the blob URL keeps a ~2MB screenshot
// attachment from also costing ~2.7MB of JS heap as a data URL.
// The base64 promise is awaited inside handleSend.
// Preview via blob: URL; base64 only materializes at send (saves ~2.7MB JS heap per attachment).
Array.from(files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const previewUrl = URL.createObjectURL(file);
@@ -838,22 +787,11 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
let trimmed = serialized.trim();
if (!trimmed) return;
// Onboarding bus signal — step 3 (launch agent), step 5/6 (agent uses
// browser / agent controls agents), step 8 (make an App) all wait for
// the user to actually send a message before the cursor advances.
onboardingBus.emit('chat:message_sent');
// The App Builder chat lives inside ViewEditor (`/apps/new`) — when
// the user submits there, the underlying agent generates an app.
// Surface this as app:generation_started so step 8 can advance from
// its typing op to its "wait for app to land" op.
if (window.location.hash.includes('/apps/')) {
onboardingBus.emit('app:generation_started');
}
// Slash commands (Phase 2). Parsed client-side so we don't pollute
// the agent loop with meta-actions; calls the corresponding backend
// endpoint and clears the input. /context is pure-frontend (toggle
// a drawer); /compact and /clear hit session endpoints.
if (sessionId && trimmed.startsWith('/')) {
const cmd = trimmed.split(/\s+/)[0].toLowerCase();
const handled = await handleSlashCommand(cmd, sessionId);
@@ -866,11 +804,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}
const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? [];
// Materialize image base64 at send time so we don't keep ~2.7MB
// strings in component state for every attached screenshot. Images
// added via addImageFiles carry a File reference (_file) and read
// their bytes on demand; legacy paste flows that wrote `data`
// directly still work. FileReader is async, so this is a Promise.all.
let allImages: Array<{ data: string; media_type: string }> = [];
if (images.length > 0) {
allImages = await Promise.all(images.map(async (img) => {
@@ -964,11 +897,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
);
editor.innerHTML = '';
_draftStore.delete(ownerId);
// Revoke any blob: URLs we minted for previews so the underlying
// bytes can be freed by the browser.
for (const img of images) {
if (img.preview?.startsWith('blob:')) {
try { URL.revokeObjectURL(img.preview); } catch { /* nothing */ }
try { URL.revokeObjectURL(img.preview); } catch {}
}
}
setImages([]);
@@ -984,25 +915,16 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (result) {
setPicker(result);
} else {
// Bail when picker is already hidden. Previously this spread a new
// object on every keystroke (`{...p, visible:false}`), which React
// saw as a state change and re-rendered ChatInput (2400 lines) on
// every keypress — that was the 199ms input delay on typing.
// Bailout when already hidden; otherwise spreading a new object re-renders all of ChatInput on every keystroke (~199ms input delay).
setPicker((p) => p.visible ? { ...p, visible: false } : p);
}
}, []);
// Set in handlePaste before execCommand fires the synthetic input event,
// so handleInput can skip the heavy post-input scans that paste can't
// possibly invalidate (paste never adds skill pills, never starts a
// slash/at trigger sequence, and the content is non-empty by definition).
// Set by handlePaste before the synthetic input fires so handleInput skips post-input scans paste can't invalidate.
const justPastedRef = useRef(false);
const handleInput = useCallback(() => {
if (justPastedRef.current) {
// Fast path for paste: set hasContent without scanning textContent,
// skip detectTrigger / syncAttachedSkills, and defer the heavy
// innerHTML draft serialization. The flag is one-shot.
justPastedRef.current = false;
setHasContent(true);
scheduleDraftSave(ownerId, () => editorRef.current?.innerHTML ?? '');
@@ -1091,13 +1013,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
e.preventDefault();
return;
}
// Cmd/Ctrl+L → clear the chat (matches Claude Code's convention).
// Empties the editor + visible transcript, and resets the SDK
// session id server-side so the next message starts in fresh context.
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l' && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (sessionId) {
handleSlashCommand('/clear', sessionId).catch(() => { /* surfaced via context_status */ });
handleSlashCommand('/clear', sessionId).catch(() => {});
dispatch(clearSessionMessages(sessionId));
}
const editor = editorRef.current;
@@ -1200,11 +1119,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const removeImage = useCallback((idx: number) => {
setImages((prev) => {
const removed = prev[idx];
// Revoke the blob URL we minted in addImageFiles so the browser
// can free the underlying bytes; data URLs have no resource to
// revoke so the `blob:` check is sufficient.
if (removed?.preview?.startsWith('blob:')) {
try { URL.revokeObjectURL(removed.preview); } catch { /* nothing */ }
try { URL.revokeObjectURL(removed.preview); } catch {}
}
return prev.filter((_, i) => i !== idx);
});
@@ -1216,10 +1132,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
minWidth: 180,
// Hard cap so a long unbreakable error string in the probe
// banner can't stretch the menu wider than its sensible
// content. 380px fits the longest model label comfortably
// (≈ 56 chars at 0.8rem) without sprawling across the screen.
maxWidth: 380,
maxHeight: 400,
boxShadow: c.shadow.lg,
@@ -1367,13 +1279,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
{contextPaths.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: images.length > 0 ? 0.25 : 1, pb: 0 }}>
{contextPaths.map((cp, idx) => {
// Friendlier label for the App Builder's auto-attached
// workspace directory. Without this special-case, the chip
// shows `outputs_workspace/ws-mp3pasq6` — a path the user
// never typed and has no idea what it means. The full path
// still lives in the tooltip for any agent/dev who needs
// it. Pattern is stable: every App Builder workspace path
// ends in `outputs_workspace/ws-<random>`.
const isAppWorkspace = /\/outputs_workspace\/ws-[^/]+\/?$/.test(cp.path);
const label = isAppWorkspace
? 'App files'
@@ -1523,9 +1428,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
contentEditable={!disabled}
suppressContentEditableWarning
spellCheck
// autoCorrect/autoCapitalize are no-ops on Chromium but harmless,
// and make the input behave correctly if anyone runs the web build
// on iOS Safari.
autoCorrect="on"
autoCapitalize="sentences"
onInput={handleInput}
@@ -1564,7 +1466,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
userSelect: 'none',
}}
>
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued type another or wait…` : 'Agent is working messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued, type another or wait…` : 'Agent is working, messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
</div>
)}
</Box>
@@ -1665,13 +1567,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
// We focus our own search input (effect above), so don't let
// the Menu auto-focus the first MenuItem — that would steal
// focus and block typing.
autoFocus={false}
MenuListProps={{ autoFocusItem: false }}
>
{/* Sticky header. Stops click+key so Menu doesn't typeahead while typing. */}
{/* Sticky header stops click+key so Menu doesn't typeahead while user types. */}
<Box
onKeyDown={(e) => {
if (e.key !== 'Escape') e.stopPropagation();
@@ -1753,9 +1652,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Tooltip>
</Box>
<Collapse in={filtersExpanded} timeout={180} unmountOnExit>
{/* Boolean capability chips — Reasoning / Free / Subscription.
"Cheap" + "≥200K" got promoted to the slider rows below,
where they're continuous rather than discrete. */}
<Box sx={{
px: 1.25, height: 28,
display: 'flex', alignItems: 'center', gap: 0.5,
@@ -1872,7 +1768,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Collapse>
</Box>
{/* Probe warning, capped to one line — full text on hover. */}
{probeResult && probeResult.value === model && !probeResult.ok && (
<Tooltip title={probeResult.error || 'health check failed'} placement="bottom-start" enterDelay={400}>
<Box
@@ -1989,11 +1884,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const isOpenSwarmPro = prov === 'OpenSwarm Pro';
const isOR = prov.startsWith('OpenRouter');
const ms = models as any[];
// Every group is collapsible — chevron + member-count
// badge appear on every header for consistency. OR vendor
// groups with >12 entries auto-collapse on first open;
// everything else starts expanded. Search disables auto-
// collapse so matches stay visible while typing.
// OR vendor groups with >12 entries auto-collapse on first open; search disables this.
const collapsible = true;
const searchActive = modelSearch.trim().length > 0;
const userToggle = collapsedGroups[prov];
@@ -2071,7 +1962,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Typography>
</Box>
</MenuItem>,
// 180ms matches the Filters tray for visual consistency.
<Collapse
key={`coll-${prov}`}
in={!collapsed}
@@ -2140,7 +2030,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}}
>
<Box component="span" sx={{ flexShrink: 0, pointerEvents: 'none' }}>
Type to search · Esc to close
Type to search, Esc to close
</Box>
{(() => {
const breakdown: Array<[string, number]> = ([
@@ -2197,7 +2087,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Box>
</Menu>
{/* Thinking-level picker — only rendered for reasoning-capable models */}
{(() => {
const currentModel = allModelOptions.flat.find((m: any) => m.value === model) as any;
if (!currentModel?.reasoning || !onThinkingLevelChange) return null;
@@ -2242,11 +2131,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
Thinking Level
</Typography>
</MenuItem>
{/* Gemini 3 preview models conflict with web search when
thinking is on Gemini's API rejects with "thought
signature is not valid" the next turn after a tool call.
Surface a note here so users hit on search issues know
which toggle to flip. */}
{/* Gemini 3 preview rejects "thought signature" on tool-call turns when thinking is on; warn search users. */}
{(() => {
const isGemini3 = typeof model === 'string' && (model.includes('gemini-3') || (allModelOptions.flat.find((m: any) => m.value === model)?.label || '').toLowerCase().includes('gemini 3'));
if (!isGemini3 || thinkingLevel === 'off') return null;
@@ -2480,8 +2365,5 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
ChatInput.displayName = 'ChatInput';
// Memoize across parent re-renders driven by unrelated state (most
// commonly AgentChat re-rendering because its session-local data
// updated). The parent passes callbacks via useCallback and primitive
// props, so the default shallow comparison is correct.
// Shallow memo: AgentChat re-renders from unrelated session-local state shouldn't churn ChatInput.
export default React.memo(ChatInput);
@@ -4,16 +4,7 @@ import Typography from '@mui/material/Typography';
import UnfoldLessOutlinedIcon from '@mui/icons-material/UnfoldLessOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Inline marker rendered immediately after the message identified by
// `compacted_through_msg_id`. The auto-compaction routine summarizes older
// turns into a single block; without a visible cue, the transcript would
// just appear to "skip" — users assume the agent forgot something. The chip
// makes it clear the older turns are still in scope, just collapsed.
//
// Click is currently a no-op (we don't surface the summary text yet); the
// affordance reads as "hover for info" via the cursor style only. If we
// later persist the summary text in the session, expand-to-reveal lands
// here.
/** Chip marking where auto-compaction collapsed older turns, so the transcript doesn't just appear to skip. */
const CompactionMarker: React.FC<{ collapsedCount: number }> = ({ collapsedCount }) => {
const c = useClaudeTokens();
const label = collapsedCount > 0
@@ -7,15 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Drawer triggered by /context slash command. Shows what the model
// actually sees this session: active MCPs, active Outputs, ctx%, cache
// hit rate, compaction state. Pure-frontend — reads off the slice that
// agent:context_update keeps fresh.
//
// The trigger is a window CustomEvent dispatched from the slash command
// handler in ChatInput.tsx. Keeping it event-driven avoids prop-drilling
// through the whole AgentChat tree.
/** /context drawer: shows session MCPs, ctx%, cache hits, compaction. Opened via window CustomEvent from ChatInput's slash handler. */
export default function ContextDrawer() {
const c = useClaudeTokens();
const [openFor, setOpenFor] = useState<string | null>(null);
@@ -77,7 +69,7 @@ export default function ContextDrawer() {
</Box>
</Box>
<Section title="Active MCP servers" emptyText="None model must MCPSearch + MCPActivate to use one">
<Section title="Active MCP servers" emptyText="None; model must MCPSearch + MCPActivate to use one">
{(session.active_mcps || []).map((m) => (
<Pill key={m} label={m} color={c.accent.primary} />
))}
@@ -96,13 +88,13 @@ export default function ContextDrawer() {
<Section title="Tips" emptyText="">
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 0.5 }}>
<code>/compact</code> summarize old turns now
<code>/compact</code>: summarize old turns now
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 0.5 }}>
<code>/clear</code> fresh SDK session, keep chat history visible
<code>/clear</code>: fresh SDK session, keep chat history visible
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block' }}>
<code>/context</code> open this drawer
<code>/context</code>: open this drawer
</Typography>
</Section>
</Box>
@@ -150,11 +150,7 @@ const MessageActionBar: React.FC<Props> = ({
);
};
// Action bar callbacks are inline arrow functions from AgentChat (closing
// over the per-message msg.id), so default memo equality fails on every
// parent render. Compare by callback presence + branch-nav primitives
// instead. The closures themselves are stable in EFFECT because they're
// keyed off msg.id, which is invariant for a given MessageBubble.
// Callbacks are per-message inline arrows; compare by presence + branch-nav primitives since closures are msg-id-keyed.
export default React.memo(MessageActionBar, (prev, next) => (
prev.role === next.role
&& !!prev.onCopy === !!next.onCopy
@@ -33,7 +33,6 @@ const streamingCursorKeyframes = `
}
`;
// shimmer-on-text effect for thinking. background-clip:text + sliding gradient.
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: 200% 0; }
@@ -71,7 +70,7 @@ interface OpenSwarmErrorInfo {
ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist';
}
// raw error text into a friendly card. null = not ours, render as markdown.
/** Parses raw error text into a friendly card; returns null when the error isn't one we recognize. */
function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
if (!text) return null;
if (/rate_limit_error|reached your OpenSwarm.*plan limit|Usage cap exceeded/i.test(text)) {
@@ -86,7 +85,6 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'upgrade',
};
}
// backend retried for ~5.5min before bubbling. show a soft hiccup, not a cap.
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
return {
kind: 'network',
@@ -94,7 +92,6 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
detail: 'That request timed out after a few retries. Send the message again to continue.',
};
}
// tool schemas overflowed the window. M365 alone is 141 actions.
if (/Prompt is too long|prompt_too_long|input length and `max_tokens`|context length/i.test(text)) {
return {
kind: 'too_many_tools',
@@ -103,8 +100,8 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
"Haiku is fast but has the smallest memory of the three Claude models. " +
"Each connected app adds instructions Claude has to read before it can answer, " +
"and you've added more than Haiku can hold in one go. Either turn off a few apps " +
"(Microsoft 365 is the heaviest by far), or switch to Sonnet or Opus both have " +
"5× more room.",
"(Microsoft 365 is the heaviest by far), or switch to Sonnet or Opus; both have " +
"5x more room.",
ctaLabel: 'Open Settings',
ctaAction: 'settings',
};
@@ -118,7 +115,7 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'settings',
};
}
// strict matchers only. bare "network" used to false-match Python traces.
// Strict matchers only; bare "network" false-matched Python tracebacks.
if (/\b(?:ECONNREFUSED|ENETUNREACH|ENOTFOUND|EAI_AGAIN)\b|Could\s+not\s+reach\s+OpenSwarm|Unable\s+to\s+connect\s+to\s+OpenSwarm/i.test(text)) {
return {
kind: 'network',
@@ -466,8 +463,6 @@ const MessageImageThumbnails: React.FC<{
);
};
// 17 single-word labels picked deterministically per turn from the message id.
// Mostly normal-warm with a kitchen cluster + a few gen-z picks for variety.
const THINKING_LABELS: ReadonlyArray<{ live: string; past: string }> = [
{ live: 'Thinking', past: 'Thought' },
{ live: 'Pondering', past: 'Pondered' },
@@ -488,10 +483,7 @@ const THINKING_LABELS: ReadonlyArray<{ live: string; past: string }> = [
{ live: 'Brewing', past: 'Brewed' },
];
// Stable hash of the message id label index. Same message always shows the
// same label — so reload / scroll-back / resume don't shuffle history. Cheap:
// 6 ops per char, but the id is 32 hex chars so ~200 ops total per bubble,
// completely negligible vs. a single React re-render.
/** Stable hash of message id to label index; reload, scroll-back, and resume keep the same label. */
function labelIndexFromId(id: string | undefined): number {
if (!id) return 0;
let h = 0;
@@ -506,12 +498,11 @@ const ThinkingBubble: React.FC<{
isStreaming?: boolean;
timestamp?: string;
messageId?: string;
// server-stamped totals for the turn. survives unmount.
persistedElapsedMs?: number;
persistedTokens?: number;
persistedInputTokens?: number;
persistedToolCount?: number;
// aux-LLM label like "Auditing the pull request". null = use the heuristic.
// Aux-LLM label like "Auditing the pull request"; null falls back to heuristic.
dynamicLabel?: string | null;
}> = ({ content, isStreaming, messageId, persistedElapsedMs, persistedTokens, persistedInputTokens, persistedToolCount, dynamicLabel }) => {
const c = useClaudeTokens();
@@ -521,7 +512,6 @@ const ThinkingBubble: React.FC<{
[messageId],
);
// live timer is just the fallback. server-stamped values win.
const [startedStreamingAt, setStartedStreamingAt] = useState<number | null>(
isStreaming ? Date.now() : null
);
@@ -541,13 +531,12 @@ const ThinkingBubble: React.FC<{
return () => clearInterval(iv);
}, [isStreaming, startedStreamingAt]);
// expanded while streaming, collapsed after. userOverride pins explicit clicks.
// userOverride pins explicit clicks; default is expanded while streaming, collapsed after.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const expanded = userOverride ?? !!isStreaming;
const toggle = () => setUserOverride(!expanded);
const text = typeof content === 'string' ? content : JSON.stringify(content);
// 3.6 chars/token for English. swap for persistedTokens once the stream ends.
const liveTokenEstimate = isStreaming ? Math.max(0, Math.round(text.length / 3.6)) : 0;
const persistedSecs = persistedElapsedMs != null
@@ -561,7 +550,7 @@ const ThinkingBubble: React.FC<{
?? (text && !isStreaming ? Math.max(1, Math.round(text.length / 3.6)) : null);
const activeLabel = dynamicLabel
? (liveTokenEstimate > 0 ? `${dynamicLabel} · ~${liveTokenEstimate} tokens` : `${dynamicLabel}`)
? (liveTokenEstimate > 0 ? `${dynamicLabel}… ~${liveTokenEstimate} tokens` : `${dynamicLabel}`)
: (liveTokenEstimate > 0 ? `${turnLabel.live}… (~${liveTokenEstimate} tokens)` : `${turnLabel.live}`);
const fmtTokens = (n: number) => {
@@ -572,7 +561,6 @@ const ThinkingBubble: React.FC<{
return String(n);
};
// 251s reads as "4m 11s". mirrors AgentCard's fmtSeconds.
const fmtThoughtDuration = (sec: number) => {
if (sec < 60) return `${sec}s`;
const minutes = Math.floor(sec / 60);
@@ -585,13 +573,11 @@ const ThinkingBubble: React.FC<{
return remMin > 0 ? `${hours}h ${remMin}m` : `${hours}h`;
};
// input_tokens is the full turn cost (parent + subagents + tool MCPs).
// legacy messages without it fall back to output-only.
// input_tokens is full turn cost (parent + subagents + tool MCPs); legacy messages fall back to output-only.
const combinedTotalTokens =
persistedInputTokens != null && persistedInputTokens > 0
? persistedInputTokens
: finalTokens;
// tooltip breakdown. legacy data without finalTokens shows total only.
const tokenBreakdown = (() => {
if (combinedTotalTokens == null || combinedTotalTokens <= 0) return null;
if (finalTokens == null || finalTokens <= 0) {
@@ -626,7 +612,7 @@ const ThinkingBubble: React.FC<{
<Box sx={{ mt: 0.5, color: c.text.ghost, fontSize: '0.7rem', fontStyle: 'italic' }}>
Input shown is your message, history, and tool outputs. The fixed
framework preamble (system prompt, tool defs, MCP descriptions) is
excluded that part is constant overhead from the agent runtime,
excluded, since it's constant overhead from the agent runtime,
not anything you can shrink.
</Box>
</Box>
@@ -635,7 +621,7 @@ const ThinkingBubble: React.FC<{
{total.toLocaleString()} tokens (input + output + children)
</Box>
);
segments.push(<span key="sep-1"> · </span>);
segments.push(<span key="sep-1">, </span>);
segments.push(
<Tooltip
key="tokens"
@@ -659,7 +645,7 @@ const ThinkingBubble: React.FC<{
);
}
if (persistedToolCount != null && persistedToolCount > 0) {
segments.push(<span key="sep-2"> · </span>);
segments.push(<span key="sep-2">, </span>);
segments.push(
<span key="tools">{persistedToolCount} tool{persistedToolCount === 1 ? '' : 's'} used</span>
);
@@ -667,7 +653,7 @@ const ThinkingBubble: React.FC<{
return segments;
};
// shimmer needs a flat string. post-stream uses nodes for the tooltip.
// Shimmer needs a flat string; post-stream label needs nodes for the token tooltip.
const label: React.ReactNode = isStreaming ? activeLabel : renderPostStreamLabel();
const shimmerBase = c.text.tertiary;
@@ -755,7 +741,7 @@ const ThinkingBubble: React.FC<{
);
};
// fallback when the model thought but the provider didn't expose the text.
// Shown when the model thought but the provider didn't expose the text.
const ProviderReasoningExplanation: React.FC<{
isStreaming: boolean;
tokens: number | null;
@@ -779,13 +765,13 @@ const ProviderReasoningExplanation: React.FC<{
if (tokens && tokens > 0) {
segs.push(`${tokens.toLocaleString()} reasoning tokens`);
}
return segs.join(' · ');
return segs.join(', ');
})();
const variants = [
"It's still thinking we just aren't allowed to peek behind the curtain.",
"It's still thinking, we just aren't allowed to peek behind the curtain.",
"Wheels are turning, but this provider keeps its thoughts private.",
"Brain's busy back there; the provider just isn't letting us listen in.",
"Mulling it over quietly — only Claude shows its work out loud.",
"Mulling it over quietly. Only Claude shows its work out loud.",
"Thinking happened, just not in the open. (GPT and Gemini play their cards close.)",
"Reasoning's underway, but this provider doesn't broadcast it. Trust the process.",
];
@@ -862,10 +848,9 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
>{rawText}</ReactMarkdown>
), [rawText]);
// upstream errors get a friendly card.
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
// fire once per cap card. (message.id, kind) keeps it from re-firing on edits.
// (message.id, kind) keys so cap card analytics fire once, not on edits.
React.useEffect(() => {
if (openswarmError?.kind === 'cap') {
report('subscription', 'rate_limit_hit', { message_id: message.id });
@@ -894,7 +879,6 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
? content.slice(0, 200)
: JSON.stringify(content).slice(0, 200);
// pending = dim, failed = red tint.
const optimisticStatus = (message as any).optimistic_status as 'pending' | 'failed' | undefined;
const isPending = optimisticStatus === 'pending';
const isFailed = optimisticStatus === 'failed';
@@ -908,7 +892,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
my: 0.75,
// contain: reflow inside this bubble doesn't shake the transcript.
// Isolates reflow so an expanding bubble doesn't shake the transcript.
contain: 'layout style',
}}
>
@@ -7,44 +7,14 @@ interface Props {
sessionId: string;
activeBranchId: string;
turnLabel?: string | null;
// Called when the streamed content grows so the host scroll container
// can stick to the bottom. We pass a callback instead of doing the
// scroll math here so AgentChat keeps ownership of its scroll state
// (isAtBottomRef etc.). The callback is invoked from a RAF, so it's
// safe to do DOM reads/writes inside.
onStreamGrew?: () => void;
}
// Leaf component that subscribes to the streaming entry for a single
// session and renders the appropriate bubble. Isolating this in its own
// component is what keeps AgentChat from re-rendering on every painted
// character. AgentChat only knows whether a stream exists (boolean
// selector elsewhere), not the per-character content. StreamingBubble
// itself does re-render at the streaming rate, but it has no children
// beyond a MessageBubble/ToolCallBubble, so React reconciliation stays
// local and cheap.
/** Leaf subscriber for one session's streaming entry; isolates re-renders so AgentChat doesn't churn per character. */
const StreamingBubble: React.FC<Props> = ({ sessionId, activeBranchId, turnLabel, onStreamGrew }) => {
const streamingMessage = useStreamingMessage(sessionId);
// Render the raw streaming content as it arrives. We tried a
// client-side typewriter (word + char chunking, punctuation pauses)
// but it introduced two real problems:
// 1. The pacing was slower than Claude's actual emit rate, so the
// visible response lagged behind real arrival by 2-3x.
// 2. On stream_end, useStreamingMessage clears and the streamed
// partial vanished, then the final message rendered all at
// once via MessageBubble. That's the "no streaming, just a
// huge block" experience.
// The other isolation work (streamingSlice, RAF WS batching,
// StreamingBubble as a leaf) is what actually makes streaming feel
// smooth: the React tree above this component stays dormant, the
// browser renders one growing text node per frame, and the user
// sees tokens arrive at the model's natural pace.
const typedContent = streamingMessage?.content ?? '';
// Fire onStreamGrew once per render (i.e. per delta) on a RAF so the
// host can scroll if it wants to. RAF coalesces multiple deltas in
// the same frame into one host call. The ref-callback keeps the
// useEffect dep array minimal: we don't want to re-run effects on
// every callback identity change from the parent.
// RAF-coalesce so onStreamGrew fires once per frame regardless of token rate.
const onGrewRef = useRef(onStreamGrew);
onGrewRef.current = onStreamGrew;
const rafRef = useRef<number | null>(null);
@@ -74,10 +44,6 @@ const StreamingBubble: React.FC<Props> = ({ sessionId, activeBranchId, turnLabel
call={{
id: streamingMessage.id,
role: 'tool_call',
// typedContent feeds the SAME typewriter through tool-call
// arguments so the args reveal at the same RPG rhythm the
// user sees in regular messages, rather than slamming in
// whenever the server bursts.
content: { tool: streamingMessage.tool_name || '', input: typedContent },
timestamp: new Date().toISOString(),
branch_id: activeBranchId,
@@ -30,17 +30,11 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi
if (service === 'gmail') {
return (
<svg width={size} height={size} viewBox="0 0 24 10" fill="none" style={{ flexShrink: 0 , marginBottom: '6px'}}>
{/* Left blue bar */}
<path d="M2 6.5V18a2 2 0 002 2h1V8l-3-1.5z" fill="#4285F4"/>
{/* Right green bar */}
<path d="M22 6.5V18a2 2 0 01-2 2h-1V8l3-1.5z" fill="#34A853"/>
{/* Red M chevron */}
<path d="M5 8v12h2V10.2L12 14l5-3.8V20h2V8l-7 5.25L5 8z" fill="#EA4335"/>
{/* Top-left blue triangle */}
<path d="M4 4a2 2 0 00-2 2.5L5 8V4H4z" fill="#4285F4"/>
{/* Top-right yellow triangle */}
<path d="M20 4a2 2 0 012 2.5L19 8V4h1z" fill="#FBBC04"/>
{/* Top red V */}
<path d="M19 4H5v4l7 5.25L19 8V4z" fill="#EA4335"/>
</svg>
);
@@ -172,9 +166,6 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName };
const serverSlug = m[1];
const action = m[2];
// Sentence case: first word capitalized, rest lowercase. Reads "Get
// message details" not "Get Message Details" — the Linear/Notion/Stripe
// convention. Title Case feels marketing-y on every row.
const spaced = action.replace(/_/g, ' ').toLowerCase();
const display = spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -213,9 +204,6 @@ function getInputSummary(toolName: string, input: any): string {
const n = toolName.toLowerCase();
if (isBashTool(toolName)) {
// Verb is in the tool label ("Deleted", "Pulled from git", …);
// surface only the target so the row reads "Deleted foo.ts" instead
// of leaking the full shell command. Raw command stays in the body.
return bashCommandDetail(input.command || '');
}
if (n === 'read' || n === 'write' || n === 'edit' || n === 'multiedit' || n === 'strreplace')
@@ -231,7 +219,7 @@ function getInputSummary(toolName: string, input: any): string {
if (n === 'webfetch') return prettyUrl(input.url || '');
if (n === 'todoread' || n === 'todowrite') return '';
if (n === 'ls') return prettyPath(input.path || '.');
if (n === 'mcpactivate') return ''; // label already says "Connecting to X"
if (n === 'mcpactivate') return '';
if (n === 'mcpsearch' || n === 'outputsearch') return quoteQuery(input.query || '');
if (n === 'outputactivate') return input.output_id || '';
if (n === 'renderoutput') return input.output_id || '';
@@ -1182,17 +1170,12 @@ const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> =
if (service === 'calendar') return <CalendarCard data={data} hideHeader={compact} />;
if (service === 'drive' || service === 'sheets') return <DriveCard data={data} />;
// Plain-text MCP results (our openswarm-web DDG search, fetch, etc.) arrive
// as `[{type:"text", text:"..."}]` which the parser extracts into `rawText`
// but leaves `data` empty. Render the rawText directly so users see the
// actual tool output instead of "(empty response)". Display is capped —
// the model still receives the full payload, only the UI preview is
// trimmed so a 250 KB fetch doesn't blow up the chat bubble.
// Plain-text MCP results: render rawText capped at 6000 chars (model still sees full payload).
const hasData = data && Object.keys(data).length > 0;
if (!hasData && rawText && rawText.trim()) {
const DISPLAY_CAP = 6000;
const preview = rawText.length > DISPLAY_CAP
? rawText.slice(0, DISPLAY_CAP) + `\n… (${rawText.length - DISPLAY_CAP} more chars model received full output)`
? rawText.slice(0, DISPLAY_CAP) + `\n… (${rawText.length - DISPLAY_CAP} more chars; model received full output)`
: rawText;
return (
<Box sx={{ px: 1.5, py: 1 }}>
@@ -1429,8 +1412,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
const promptPrefix = getPromptPrefix(toolName);
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
// mcpCompact rows live inside a ToolGroup whose header already shows
// the brand + count, so the row uses the verb form, not the brand.
const mcpVerbLabel = (() => {
const lbl = getToolLabel(toolName, call.id);
return result && !isDenied ? lbl.past : lbl.present;
@@ -1468,7 +1449,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
transition: 'border-color 0.3s, box-shadow 0.3s',
} as any}
>
{/* Header */}
<Box
onClick={toggle}
sx={{
@@ -1586,7 +1566,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
</Box>
{/* Expanded body — markdown rendered, not terminal */}
<Collapse in={expanded && hasResponse}>
<Box
sx={{
@@ -1979,7 +1958,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
transition: 'border-color 0.3s, box-shadow 0.3s',
} as any}
>
{/* Header */}
<Box
onClick={toggle}
sx={{
@@ -2011,7 +1989,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
}}
>
{(() => {
// call.id seeds the variant pool so re-renders are stable.
const { present, past } = getToolLabelWithInput(toolName, input, call.id);
return result && !isDenied ? past : present;
})()}
@@ -2091,7 +2068,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
</Box>
{/* Unified terminal body */}
<Collapse in={showBody}>
<Box
sx={{
@@ -2107,7 +2083,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
},
}}
>
{/* Prompt + command */}
<pre
style={{
margin: 0,
@@ -2142,7 +2117,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
</pre>
{/* Browser agent inline feed */}
{isBrowserAgent && sessionId && (
<BrowserAgentInlineFeed
parentSessionId={sessionId}
@@ -2150,7 +2124,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
/>
)}
{/* Output */}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} />
) : parsedResult ? (
@@ -2191,7 +2164,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
</pre>
) : null}
{/* Pending indicator when waiting for result (skip for browser agent — feed replaces it) */}
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}>
<Box
@@ -174,7 +174,6 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
<Box
sx={{
borderTop: `0.5px solid ${c.border.medium}`,
// 140ms fade so rows don't pop in.
'& > *': {
animation: 'toolRowFadeIn 140ms ease-out',
},
+7 -18
View File
@@ -1,12 +1,9 @@
// Tool labels, with variant pools so the transcript reads like a person.
// Destructive ops (rm, git push, delete) stay flat. quirky on rm felt off.
export interface ToolLabel {
present: string;
past: string;
}
// djb2. same seed always picks the same variant so rows don't flicker.
// djb2 hash; same seed always picks the same variant so rows don't flicker.
function _stableIndex(seed: string | undefined, n: number): number {
if (n <= 1 || !seed) return 0;
let h = 5381;
@@ -20,7 +17,7 @@ function _pick<T>(variants: T[], seed?: string): T {
return variants[_stableIndex(seed, variants.length)];
}
// index 0 is the safe-default; single-entry pools = no seeded variation.
// Index 0 is the safe default; single-entry pools mean no seeded variation.
const VARIANTS: Record<string, ToolLabel[]> = {
read: [
{ present: 'Reading', past: 'Read' },
@@ -157,7 +154,7 @@ const VARIANTS: Record<string, ToolLabel[]> = {
{ present: 'Browsing the toolbox', past: 'Browsed the toolbox' },
{ present: 'Rummaging the toolbox', past: 'Rummaged the toolbox' },
],
// brand-aware version lives in getToolLabelWithInput; this is the fallback.
// getToolLabelWithInput has the brand-aware version; this is the seedless fallback.
mcpactivate: [
{ present: 'Connecting', past: 'Connected' },
{ present: 'Plugging in', past: 'Plugged in' },
@@ -270,7 +267,7 @@ const VARIANTS: Record<string, ToolLabel[]> = {
],
};
// keys match _sanitize_server_name in tools_lib.
// Keys match backend _sanitize_server_name in tools_lib.
const MCP_SERVER_BRAND: Record<string, string> = {
'google-workspace': 'Google Workspace',
'microsoft-365': 'Microsoft 365',
@@ -299,7 +296,7 @@ const MCP_SERVER_BRAND: Record<string, string> = {
'openswarm-outputs-meta': 'views',
};
// most specific verb pattern wins, so order matters.
// Order matters: most specific verb pattern wins.
interface McpVerbVariant { present: string; past: string; }
const MCP_VERB_PATTERNS: Array<{ match: RegExp; variants: McpVerbVariant[] }> = [
{ match: /^(send|new)_/, variants: [
@@ -341,7 +338,6 @@ const MCP_VERB_PATTERNS: Array<{ match: RegExp; variants: McpVerbVariant[] }> =
{ present: 'Refining', past: 'Refined' },
{ present: 'Touching up', past: 'Touched up' },
]},
// delete = flat. don't be cute about deletions.
{ match: /^(delete|remove|cancel|archive)_/, variants: [
{ present: 'Deleting', past: 'Deleted' },
]},
@@ -406,7 +402,6 @@ const ACTION_OBJECTS: Array<{ match: RegExp; noun: string }> = [
{ match: /(?:^|_)(?:task|todo)/, noun: 'task' },
];
// sentence case (Linear/Notion vibe). title case felt too marketing-y.
function _humanizeName(name: string): string {
const spaced = name.replace(/[-_]+/g, ' ').toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -419,7 +414,7 @@ function _labelForMcpTool(toolName: string, seed?: string): ToolLabel | null {
const action = parts.slice(2).join('__').toLowerCase();
const brand = MCP_SERVER_BRAND[server] || _humanizeName(server);
// our internal meta-MCPs go through VARIANTS so we don't render "tools: Mcpsearch".
// Internal meta-MCPs route through VARIANTS so we don't render "tools: Mcpsearch".
if (server.startsWith('openswarm-')) {
const builtin = VARIANTS[action];
if (builtin) return _pick(builtin, seed);
@@ -443,7 +438,6 @@ function _labelForMcpTool(toolName: string, seed?: string): ToolLabel | null {
return { present: `${verb.present} via ${brand}`, past: `${verb.past} via ${brand}` };
}
// no verb match. fall back to brand: action.
const human = _humanizeName(action.replace(/^_+|_+$/g, ''));
return { present: `${brand}: ${human}`, past: `${brand}: ${human}` };
}
@@ -459,7 +453,7 @@ export function getToolLabel(toolName: string, seed?: string): ToolLabel {
return { present: `Running ${pretty}`, past: `Ran ${pretty}` };
}
// for tools where the input changes the label (MCPActivate, Bash).
/** Per-input label override for tools whose meaning depends on input (MCPActivate, Bash). */
export function getToolLabelWithInput(toolName: string, input: any, seed?: string): ToolLabel {
if (!toolName) return { present: 'Working', past: 'Done' };
@@ -488,9 +482,6 @@ export function getToolLabelWithInput(toolName: string, input: any, seed?: strin
return getToolLabel(toolName, seed);
}
// --- Bash verb extraction ---------------------------------------------------
// rm and chmod don't get cute paraphrases for obvious reasons.
const GIT_VERBS: Record<string, ToolLabel[]> = {
commit: [
{ present: 'Committing', past: 'Committed' },
@@ -855,8 +846,6 @@ function _bashVerb(rawCmd: string, seed?: string): ToolLabel | null {
return _pick<ToolLabel>(entry, seed);
}
// --- Path / URL prettifiers ------------------------------------------------
export function prettyPath(p: string): string {
if (!p) return '';
const cleaned = p.replace(/[\\/]+$/, '');
@@ -31,7 +31,7 @@ const Analytics: React.FC = () => {
Your usage
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6, mb: 3, maxWidth: 500, mx: 'auto' }}>
Usage data is automatically collected sessions, costs, tool usage, model distribution, and task categories.
Usage data is automatically collected: sessions, costs, tool usage, model distribution, and task categories.
All data is anonymous and can be disabled in Settings.
</Typography>
@@ -39,7 +39,7 @@ const Analytics: React.FC = () => {
{[
{ label: 'Sessions & Usage', desc: 'How often agents are launched, session duration, completion rates' },
{ label: 'Cost Tracking', desc: 'Spend by model, provider, and time period' },
{ label: 'Task Categories', desc: 'What users do coding, email, research, social, browsing' },
{ label: 'Task Categories', desc: 'What users do: coding, email, research, social, browsing' },
{ label: 'Model Distribution', desc: 'Which models and providers are most popular' },
{ label: 'Tool Usage', desc: 'Most used MCP tools, execution times, approval rates' },
{ label: 'Retention & Funnels', desc: 'User engagement, feature adoption, onboarding flow' },
@@ -254,7 +254,6 @@ export const CommandsContent: React.FC = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Slash Commands */}
<Box>
<SectionHeader
icon={<TerminalIcon sx={{ fontSize: 22 }} />}
@@ -352,7 +351,6 @@ export const CommandsContent: React.FC = () => {
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
{/* @ Commands */}
<Box>
<SectionHeader
icon={<AlternateEmailIcon sx={{ fontSize: 22 }} />}
@@ -441,7 +439,6 @@ export const CommandsContent: React.FC = () => {
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
{/* Keyboard Shortcuts */}
<Box>
<SectionHeader
icon={<KeyboardIcon sx={{ fontSize: 22 }} />}
@@ -452,7 +449,6 @@ export const CommandsContent: React.FC = () => {
/>
<Box sx={{ display: 'flex', gap: 4 }}>
{/* Navigation */}
<Box sx={{ flex: 1 }}>
<Typography
sx={{
@@ -491,7 +487,6 @@ export const CommandsContent: React.FC = () => {
</Box>
</Box>
{/* Actions */}
<Box sx={{ flex: 1 }}>
<Typography
sx={{
@@ -65,8 +65,6 @@ const Customization: React.FC = () => {
borderRadius: 2.5,
boxShadow: c.shadow.sm,
willChange: 'transform',
// Removed the box-shadow hover animation; border-color
// alone reads as the affordance and is layout-free.
'&:hover': { borderColor: c.accent.primary },
transition: 'border-color 0.2s',
}}
@@ -70,10 +70,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Check if the parent agent session is still running. If so, the overlay
// stays alive even when this browser-agent sub-task completes — the parent
// may send another BrowserAgent call momentarily. Only treat the overlay
// as "done" when both the browser-agent session AND the parent are terminal.
// Parent session may send another BrowserAgent call; treat overlay as done only when both sub-task and parent are terminal.
const parentStatus = useAppSelector((state) => {
if (!session.parent_session_id) return null;
return state.agents.sessions[session.parent_session_id]?.status ?? null;
@@ -82,11 +79,8 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const isRunning = session.status === 'running' || session.status === 'waiting_approval';
const browserDone = session.status === 'completed' || session.status === 'error' || session.status === 'stopped';
// Streaming message lives in its own slice; see streamingSlice.ts.
const streamingMessage = useStreamingMessage(session.id);
// Only truly "done" (fade + hide) when the parent is also finished.
// While the parent is still active, the overlay stays visible in a
// "waiting for next task" state between sub-tasks.
// Only fade+hide when parent is finished too; otherwise show a "waiting" state between sub-tasks.
const isDone = browserDone && !parentStillActive;
const intervention = session.pending_approvals?.find(
@@ -107,7 +101,6 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
}
}, [session.id]);
// Reset skip input when intervention resolves
useEffect(() => {
if (!intervention) {
setShowSkipInput(false);
@@ -166,8 +159,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const expandedH = Math.min(Math.floor(browserHeight * 0.6), browserHeight - 24);
const panelW = intervention ? Math.min(340, browserWidth - 24) : expanded ? expandedW : collapsedW;
// When intervention is active, auto-size to fit content instead of a
// fixed height — otherwise the Done button gets clipped below the fold.
// Intervention auto-sizes to fit content; fixed height would clip the Done button.
const panelH = intervention ? undefined : expanded ? expandedH : collapsedH;
if (hidden) return null;
@@ -298,7 +290,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
)}
</Box>
{/* Body intervention prompt OR scrollable action log */}
{/* Body: intervention prompt OR scrollable action log */}
{intervention ? (
<Box sx={{ flex: 1, px: 1.25, py: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: '0.72rem', color: 'rgba(255,255,255,0.8)', lineHeight: 1.5 }}>
@@ -366,7 +358,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
color: '#000',
}}
>
Done continue
Done, continue
</Button>
<Button
size="small"
@@ -79,10 +79,7 @@ const chromeUserAgent = navigator.userAgent
.replace(/\s*Electron\/\S+/, '')
.replace(/\s*OpenSwarm\/\S+/, '');
// Read from the sync exposure first (set at preload boot, always present
// by the time modules evaluate). Fall back to the async `openswarm` API
// for backward compatibility. If you see `<openswarm:webview-preload>`
// logs in the terminal, this attached; if you don't, it didn't.
// Sync exposure set at preload boot; async API fallback for older builds.
const webviewPreloadPath: string | undefined = isElectron
? ((window as any).__OPENSWARM_WEBVIEW_PRELOAD__
|| (window as any).openswarm?.getWebviewPreloadPath?.())
@@ -150,10 +147,7 @@ const BrowserCard: React.FC<Props> = ({
const lastAction = activity.lastAction;
const [tabLocalStates, setTabLocalStates] = useState<Record<string, TabLocalState>>({});
// Electron webviews can't trigger the OS platform authenticator (see
// webview-preload.js for the WebAuthn shim). When the preload catches a
// passkey call it sends `ipc-message` "passkey-detected"; we surface a
// modal so the user knows why the sign-in didn't work.
// Electron webviews can't trigger OS platform auth; preload sends "passkey-detected" and we explain via modal.
const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false);
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
setTabLocalStates((prev) => {
@@ -175,7 +169,6 @@ const BrowserCard: React.FC<Props> = ({
setUrlBarValue(activeUrl);
}, [activeUrl, activeTabId]);
// ---- Webview ref management ----
const webviewMap = useRef<Map<string, WebviewElement>>(new Map());
const initializedTabs = useRef(new Set<string>());
const tabBarRef = useRef<HTMLDivElement>(null);
@@ -201,12 +194,7 @@ const BrowserCard: React.FC<Props> = ({
const targetUrl = tab.url;
const doLoad = () => {
wv.loadURL(targetUrl).catch(() => {});
// Lock the guest's pinch/page zoom at 1.0 so ctrl+wheel inside the
// webview never triggers Chromium's in-page zoom — the guest
// preload forwards the gesture to the host, where the dashboard
// canvas zoom takes over (issue #27). Without this lock, certain
// pages (or trackpad pinch on macOS) can still nudge the in-page
// zoom even when the wheel-event preventDefault fires.
// Lock guest zoom at 1.0 so ctrl+wheel never triggers Chromium's in-page zoom; canvas zoom takes over (issue #27).
try {
(wv as any).setVisualZoomLevelLimits?.(1, 1);
(wv as any).setZoomFactor?.(1);
@@ -226,26 +214,11 @@ const BrowserCard: React.FC<Props> = ({
};
const onIpcMessage = (e: any) => {
// Was previously logging every ipc-message. The preload forwards
// every guest-page console call as `webview-console`, so popular
// sites (anything with analytics, telemetry, dev hot reload, etc.)
// produced hundreds of host-side console.warn calls per second,
// each blocking the main thread when DevTools is open. That was
// the dominant cause of the "click-then-jump" lag on dashboards
// with browser cards. Drop the unconditional log; ipc channels
// we actually care about are handled in the branches below.
// No unconditional log; forwarded webview-console messages were causing 100s of host warns/sec and main-thread stalls.
if (e?.channel === 'passkey-detected') {
setPasskeyDialogOpen(true);
} else if (e?.channel === 'canvas-wheel-zoom') {
// ctrl/meta+wheel inside the webview — the guest preload caught
// it and forwarded the deltas + guest-local cursor coords. Convert
// guest coords → document coords using the webview's bounding rect,
// then dispatch a CustomEvent on window. useCanvasControls listens
// for this and runs the same zoom-around-cursor math its wheel
// handler uses. We do NOT dispatch a synthetic WheelEvent from the
// <webview> element — that bubble path turned out to be
// unreliable through Electron's GuestView, which is why ctrl+wheel
// over a selected browser was still getting eaten.
// Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView.
const payload = e.args?.[0] || {};
const wvRect = wv.getBoundingClientRect();
const docX = wvRect.left + (payload.clientX ?? 0);
@@ -281,10 +254,7 @@ const BrowserCard: React.FC<Props> = ({
}
};
// When a webview popup spawns while the app is in document fullscreen,
// Chromium's compositor shifts to the popup and the parent surface goes
// black with no fullscreenchange event. Drop fullscreen first so the
// popup renders normally and stays interactive.
// Exit fullscreen before popup spawns; Chromium compositor shifts and parent surface goes black silently otherwise.
const onNewWindow = () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
@@ -317,7 +287,6 @@ const BrowserCard: React.FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabIdKey, browserId, dispatch, updateTabLocal]);
// ---- Navigation (active tab) ----
const navigate = useCallback((targetUrl: string) => {
const finalUrl = resolveInput(targetUrl);
setUrlBarValue(finalUrl);
@@ -357,7 +326,6 @@ const BrowserCard: React.FC<Props> = ({
dispatch(removeBrowserCard(browserId));
}, [dispatch, browserId]);
// ---- Tab management ----
const handleAddTab = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
dispatch(addBrowserTab({ browserId, url: browserHomepage }));
@@ -372,7 +340,6 @@ const BrowserCard: React.FC<Props> = ({
dispatch(setActiveBrowserTab({ browserId, tabId }));
}, [dispatch, browserId]);
// ---- Tab drag reorder ----
const tabDragRef = useRef<{
tabId: string;
startX: number;
@@ -452,7 +419,6 @@ const BrowserCard: React.FC<Props> = ({
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [handleSwitchTab]);
// ---- Card drag via tab bar background ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
@@ -517,7 +483,7 @@ const BrowserCard: React.FC<Props> = ({
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
// Snap to 24px grid (hold Shift to bypass)
// Snap to 24px grid (Shift bypasses).
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
@@ -538,7 +504,6 @@ const BrowserCard: React.FC<Props> = ({
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, browserId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
@@ -600,7 +565,6 @@ const BrowserCard: React.FC<Props> = ({
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, browserId]);
// ---- Display calculations ----
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
@@ -616,7 +580,6 @@ const BrowserCard: React.FC<Props> = ({
const accentHover = c.accent.hover;
const accentRgb = accentColor.replace('#', '').match(/.{2}/g)?.map(h => parseInt(h, 16)).join(',') || '189,100,57';
// ---- Glow state ----
const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards);
const isGlowingFromRedux = !!glowingBrowserCards[browserId];
@@ -714,9 +677,7 @@ const BrowserCard: React.FC<Props> = ({
}),
}}
>
{/* Selection overlay only covers header area so webview stays interactive */}
{/* Rotating gradient border glow for element selection / streaming */}
{showGlow && !agentActive && (
<Box
sx={{
@@ -745,7 +706,6 @@ const BrowserCard: React.FC<Props> = ({
/>
)}
{/* Animated border glow (top edge overlay) */}
{agentActive && (
<Box
sx={{
@@ -766,7 +726,6 @@ const BrowserCard: React.FC<Props> = ({
/>
)}
{/* ====== Tab bar / drag handle ====== */}
<Box
ref={tabBarRef}
onPointerDown={handleDragPointerDown}
@@ -787,7 +746,6 @@ const BrowserCard: React.FC<Props> = ({
overflow: 'hidden',
}}
>
{/* Scrollable tab strip */}
<Box
sx={{
display: 'flex',
@@ -842,7 +800,6 @@ const BrowserCard: React.FC<Props> = ({
}),
}}
>
{/* Favicon / loading spinner */}
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, width: 14, height: 14, justifyContent: 'center' }}>
{tls?.loading ? (
<CircularProgress size={10} thickness={5} sx={{ color: accentColor }} />
@@ -858,7 +815,6 @@ const BrowserCard: React.FC<Props> = ({
)}
</Box>
{/* Title */}
<Typography
sx={{
flex: 1,
@@ -875,7 +831,6 @@ const BrowserCard: React.FC<Props> = ({
{tab.title || 'New Tab'}
</Typography>
{/* Close tab */}
<Box
className="tab-close"
onClick={(e: React.MouseEvent) => handleCloseTab(tab.id, e)}
@@ -900,7 +855,6 @@ const BrowserCard: React.FC<Props> = ({
);
})}
{/* Add tab (+) button */}
<Box
onClick={handleAddTab}
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
@@ -1082,7 +1036,7 @@ const BrowserCard: React.FC<Props> = ({
/>
)}
{/* ====== Browser body — multiple webviews stacked ====== */}
{/* Browser body: stacked webviews */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{isElementSelectMode && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none' }} />
@@ -1134,7 +1088,7 @@ const BrowserCard: React.FC<Props> = ({
</DialogTitle>
<DialogContent sx={{ pb: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>
Sorry OpenSwarm doesn't support passkeys. Please sign in with a password or another method.
Sorry, OpenSwarm doesn't support passkeys. Please sign in with a password or another method.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
@@ -1180,15 +1134,13 @@ const BrowserCard: React.FC<Props> = ({
}}
>
<Typography sx={{ fontSize: '0.68rem', color: c.status.warning }}>
iframe mode some sites may not load. Use the Electron build for full browser support.
iframe mode: some sites may not load. Use the Electron build for full browser support.
</Typography>
</Box>
</Box>
)}
{/* ===== Action micro-animations ===== */}
{/* Camera flash — screenshot */}
{/* Camera flash: screenshot */}
{(agentAction === 'screenshot' || lastAction === 'screenshot') && (
<Box
key={`flash-${activity.actionSeq}`}
@@ -1207,7 +1159,7 @@ const BrowserCard: React.FC<Props> = ({
/>
)}
{/* Scanning line get_text */}
{/* Scanning line: get_text */}
{agentAction === 'get_text' && (
<Box
sx={{
@@ -22,12 +22,7 @@ interface Props {
onMinimapPan: (panX: number, panY: number) => void;
}
// Persist the minimap open/closed state across reloads so a user who
// toggles it on doesn't lose their preference. Default is OFF — most
// users don't have enough cards on the canvas for the minimap to add
// value, and it occupies real estate. The onboarding tip in step 5/6
// surfaces the toggle so users discover it when they DO have enough on
// the canvas to benefit.
// Default OFF: most users don't have enough cards for the minimap to add value; onboarding tip surfaces the toggle later.
const MINIMAP_PREF_KEY = 'openswarm.canvas.minimap_open';
function readMinimapPref(): boolean {
if (typeof window === 'undefined') return false;
@@ -47,13 +42,12 @@ const CanvasControls: React.FC<Props> = ({ zoom, actions, onFitToView, onTidy, m
try {
window.localStorage.setItem(MINIMAP_PREF_KEY, String(next));
} catch {
/* private mode etc not fatal */
/* private mode etc, not fatal */
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0.75 }}>
{/* Minimap panel — sits above the toolbar */}
{minimapOpen && (
<Box
sx={{
@@ -70,7 +64,6 @@ const CanvasControls: React.FC<Props> = ({ zoom, actions, onFitToView, onTidy, m
</Box>
)}
{/* Toolbar */}
<Box
sx={{
display: 'flex',
@@ -33,7 +33,6 @@ const CardSearchPalette: React.FC<Props> = ({
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Build searchable items
const items = useMemo((): CardSearchItem[] => {
const result: CardSearchItem[] = [];
for (const card of Object.values(cards)) {
@@ -76,7 +76,6 @@ const DashboardViewCard: React.FC<Props> = ({
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
// ---- Drag via header ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
@@ -141,7 +140,7 @@ const DashboardViewCard: React.FC<Props> = ({
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
// Snap to 24px grid (hold Shift to bypass)
// Snap to 24px grid; Shift bypasses.
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
@@ -162,7 +161,6 @@ const DashboardViewCard: React.FC<Props> = ({
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, output.id, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
@@ -258,10 +256,8 @@ const DashboardViewCard: React.FC<Props> = ({
}}
sx={{
position: 'absolute',
// contain: iframe app repaints don't shake the rest of the dashboard.
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay
// contained to this card. See AgentCard for full rationale.
willChange: 'transform',
left: displayX,
top: displayY,
@@ -307,14 +303,7 @@ const DashboardViewCard: React.FC<Props> = ({
}),
}}
>
{/* No full-card overlay when selected. Earlier revisions used one to
enable "drag from anywhere" while the card was selected, but it
also blocked every pointer event from reaching the running app
inside the webview making selected apps non-interactive, which
is the whole point of the dashboard. Drag now happens from the
header strip (zIndex 16 below) which is always grabbable; the
rest of the card passes pointer events through to the live app.
ref kept so useOverlayScrollPassthrough still has a no-op target. */}
{/* No full-card overlay: it blocked pointer events to the live app. Drag uses the header (zIndex 16); ref kept as a no-op for useOverlayScrollPassthrough. */}
<Box
ref={scrollOverlayRef}
sx={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 0 }}
@@ -415,23 +404,7 @@ const DashboardViewCard: React.FC<Props> = ({
export default React.memo(DashboardViewCard);
// Preview body for an output card. Lives in the same file because it's
// only used here; pulled out so the runtime-status WS lifecycle is tied
// to the card's mount, not to a sibling element.
//
// Why this exists: old-mode flat outputs (output.files['index.html']
// present) can render straight from `${SERVE_BASE}/${output.id}/serve/...`
// — the legacy endpoint serves the files dict. New-mode webapp_template
// outputs have an empty files dict (the real app lives in the workspace
// dir behind Vite); their legacy serve URL 404s with
// `{"detail":"File not found in output"}`. We attach to the workspace's
// runtime, wait for runtime:status to surface a frontend_url, and point
// the webview at the live Vite server instead.
//
// While Vite is booting (cold npm install, slow disk) the placeholder
// shows so the user doesn't see the 404 JSON. Old-mode outputs without a
// workspace_id never spawn a runtime — they just render the legacy URL
// like they always did, so there's zero regression for existing apps.
// Old-mode outputs render the legacy serve URL; new-mode webapp_template outputs attach to a runtime and point the webview at Vite once frontend_url arrives.
const DashboardOutputPreview: React.FC<{
previewRef: React.Ref<ViewPreviewHandle>;
output: Output;
@@ -451,10 +424,7 @@ const DashboardOutputPreview: React.FC<{
isNewMode,
});
// While the runtime WS is still hydrating (first ~400ms after mount,
// or until status frame arrives — whichever's first), render a blank
// body instead of the booting placeholder. Prevents a "Starting
// preview…" flash on warm runtimes where status was already known.
// Blank body during hydration so warm runtimes don't flash "Starting preview..."
if (isHydrating && !frontendUrl) {
return <Box sx={{ width: '100%', height: '100%' }} />;
}
@@ -56,7 +56,6 @@ const DirectionHints: React.FC<Props> = ({ hasLeft, hasRight, hasUp, hasDown, sh
animation: `shake-${dir} 0.3s ease 2`,
});
// Show shake indicator even when there's no neighbor in that direction
const showLeft = hasLeft || shakeDirection === 'left';
const showRight = hasRight || shakeDirection === 'right';
const showUp = hasUp || shakeDirection === 'up';
@@ -64,7 +63,6 @@ const DirectionHints: React.FC<Props> = ({ hasLeft, hasRight, hasUp, hasDown, sh
return (
<>
{/* Inject shake keyframes */}
{shakeDirection && (
<style>{shakeKeyframes[shakeDirection]}</style>
)}
@@ -38,8 +38,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
// Hand-tuned palette distinct enough to skim across, gentle on the eye in
// both light and dark themes (notes use a single bg per color in either).
// Hand-tuned palette: distinct enough to skim, gentle in both themes.
const NOTE_PALETTE: Record<NoteColor, { bg: string; border: string; text: string }> = {
yellow: { bg: '#FBE89C', border: '#E0C95A', text: '#3a2e0a' },
pink: { bg: '#F8C3D0', border: '#DB94A6', text: '#3a131e' },
@@ -82,7 +81,6 @@ const NoteCard: React.FC<Props> = ({
const dispatch = useAppDispatch();
const palette = NOTE_PALETTE[color] || NOTE_PALETTE.yellow;
// ---- Drag (whole note via header) ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
@@ -100,7 +98,7 @@ const NoteCard: React.FC<Props> = ({
useEffect(() => {
if (autoFocus && textareaRef.current) {
// Defer to next frame so the card has mounted in the right position.
// Defer so the card has mounted in its final position.
const t = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(t);
}
@@ -177,7 +175,6 @@ const NoteCard: React.FC<Props> = ({
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, noteId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
@@ -267,10 +264,8 @@ const NoteCard: React.FC<Props> = ({
top: displayY,
width: displayW,
height: displayH,
// contain: reflow inside this note doesn't shake the dashboard.
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay
// contained to this note. See AgentCard for full rationale.
willChange: 'transform',
borderRadius: `${c.radius.md}px`,
bgcolor: palette.bg,
@@ -290,7 +285,7 @@ const NoteCard: React.FC<Props> = ({
'&:hover .note-controls': { opacity: 1 },
}}
>
{/* Drag header — thin strip at the top */}
{/* Drag header */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
@@ -6,11 +6,7 @@ interface AllCards {
browserCards: Record<string, BrowserCardPosition>;
}
/**
* Captures a screenshot of the dashboard viewport using Electron's native
* capturePage API. Captures the viewport as-is (current pan/zoom) to avoid
* mutating the DOM transform and causing visible flashes.
*/
/** Screenshots the dashboard viewport via Electron capturePage, leaving pan/zoom untouched. */
export async function captureDashboardThumbnail(
viewportEl: HTMLDivElement,
_contentEl: HTMLDivElement,
@@ -7,8 +7,7 @@ const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
// Maps the 1100 user setting to an internal multiplier.
// 50 (default) → 0.004, 1 → 0.0004, 100 → 0.008
// Maps the 1 to 100 user setting to an internal multiplier (50 default = 0.004).
function sensitivityToMultiplier(setting: number): number {
return 0.00008 * setting;
}
@@ -50,12 +49,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
contentBoundsRef.current = contentBounds;
const animFrameRef = useRef<number | null>(null);
const inertiaFrameRef = useRef<number | null>(null);
// Pending fit-target settle timer (see fitToCards). Cancelled when
// any new pan/zoom/animation kicks off so a stale settle never
// overrides fresh user input or a back-to-back fitToCards call.
// Cancelled on any pan/zoom/animation so a stale settle never overrides fresh input or back-to-back fitToCards.
const settleTimerRef = useRef<number | null>(null);
// ---- Velocity tracking for momentum panning ----
const velocityHistoryRef = useRef<Array<{ x: number; y: number; t: number }>>([]);
const FRICTION = 0.93;
const MIN_VELOCITY = 0.5;
@@ -136,15 +132,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
}
}, []);
// ---- Reusable animation helper ----
const cancelAnimation = useCallback(() => {
if (animFrameRef.current) {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = null;
}
// Also kill any pending fit-target settle so a stale snap doesn't
// fire after the user has started panning or after a fresh
// fitToCards call has set a different target.
// Kill pending settle so a stale snap doesn't fire after the user pans or fitToCards is recalled.
if (settleTimerRef.current !== null) {
window.clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
@@ -182,26 +175,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const el = viewportRef.current;
if (!el || !enabled) return; // Skip wheel listener when canvas is hidden
// RAF-coalesce wheel-driven state updates. Trackpads fire wheel
// events at ~120Hz; without batching, every event triggered a full
// Dashboard re-render (all hooks + selectors + the cards .map). The
// visible pan was fine because transform-only changes are cheap to
// composite, but the JS-side render storm at 120fps caused the
// "low FPS" feel during two-finger drag. Accumulating deltas per
// frame caps Dashboard re-renders at the display's refresh rate
// (usually 60Hz), with no perceptible motion difference because we
// apply all the accumulated deltas in one shot.
// RAF-coalesce wheel state updates; trackpads at 120Hz would otherwise re-render Dashboard per event.
let pendingPanDx = 0;
let pendingPanDy = 0;
let pendingZoomDy = 0;
let pendingZoomCenter: { cx: number; cy: number } | null = null;
let wheelRafId: number | null = null;
// Trackpad wheel gestures don't have a "gestureend" event; we infer
// it from idle time. ~140ms after the last wheel event we declare
// the gesture over and unset the interaction flag, which un-pauses
// ResizeObservers etc. The 140ms window is short enough to feel
// responsive on re-engage and long enough to absorb the inter-burst
// gaps inside a continuous swipe.
// No "gestureend" on trackpads; 140ms idle declares the gesture over (short enough to feel snappy, long enough to span inter-burst gaps).
let wheelIdleTimer: ReturnType<typeof setTimeout> | null = null;
const flushWheel = () => {
@@ -246,15 +226,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
};
// Cache "is this element a scrollable child" decision per node. The
// canvas wheel handler walks up from e.target to el on every event;
// without caching, it called getComputedStyle on every ancestor on
// every wheel event (120Hz from a trackpad × 5-10 ancestors × style
// recalc). That was the dominant cost of trackpad two-finger
// navigation — RAF-coalescing the state update only fixed half of
// the problem. WeakMap entries get GC'd with their elements; no
// manual invalidation needed for unmounted DOM. We do invalidate
// explicitly when a node's scroll capacity might have changed (see
// the resize observer below).
// Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change.
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
const onWheel = (e: WheelEvent) => {
@@ -267,10 +239,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX;
let target = e.target as HTMLElement | null;
while (target && target !== el) {
// Cached classification: 'scrollable' = has overflow auto/scroll
// AND content exceeds its frame in some direction. 'not' = neither.
// Fast path: cheap scrollHeight/scrollWidth read (a layout-flushing
// property, but no style recalc) before paying for getComputedStyle.
let cls = scrollableCache.get(target);
if (cls === undefined) {
const couldScroll =
@@ -290,10 +258,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
}
if (cls === 'scrollable' && !isPinchZoom) {
// Re-read scrollHeight/clientHeight here (cheap, no style recalc)
// to make the at-boundary check responsive — the cached decision
// is structural (does this element have overflow:auto/scroll AND
// exceed its frame); the current scroll position is dynamic.
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
const atYBoundary = !canScrollY ||
@@ -304,7 +269,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
(dx < 0 && target.scrollLeft <= 1);
if (atYBoundary && atXBoundary) {
// At boundary — fall through to canvas pan
target = target.parentElement;
continue;
}
@@ -385,12 +349,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
};
}, [cancelAnimation, cancelInertia]);
// RAF-coalesce mouse-drag pan. Mouse events fire at 60-240Hz; without
// batching, every event called setState directly and Dashboard
// re-rendered at the same rate, causing the "hop hop hop" feel when
// dragging the canvas with the cursor. Velocity history still captures
// per-event (for inertia accuracy on mouseup) — only the React state
// update is throttled.
// RAF-coalesce drag pan; setState per event caused "hop hop hop" feel. Velocity history still captures per-event for inertia accuracy.
const dragRafRef = useRef<number | null>(null);
const latestDragRef = useRef<{ dx: number; dy: number } | null>(null);
const flushDrag = useCallback(() => {
@@ -598,10 +557,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
animateTo({ panX: newPanX, panY: newPanY, zoom: newZoom });
}, [animateTo]);
// Pure target computation — extracted so we can re-run it after the
// animation settles and detect viewport-rect drift mid-flight (sidebar
// collapse, route switch, panel mount/unmount, etc). Returns null if
// the viewport is missing or the rect set is empty.
// Extracted so we can re-run after animation to detect viewport-rect drift (sidebar collapse, route switch).
const computeFitTarget = useCallback(
(
cardRects: Array<{ x: number; y: number; width: number; height: number }>,
@@ -659,9 +615,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const target = computeFitTarget(cardRects, maxZoom, minZoom);
if (!target) {
// Viewport unavailable / no content — keep current camera, don't
// snap to (0,0,1) which used to leave the minimap thinking it
// was centered when the canvas was anywhere.
// Keep current camera; snapping to (0,0,1) used to desync the minimap.
if (cardRects.length === 0 || !viewportRef.current) {
setState({ panX: 0, panY: 0, zoom: 1 });
}
@@ -674,14 +628,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
// Settle pass — re-run the math one frame after the animation
// ends and snap-correct any drift from viewport changes during
// the flight (sidebar collapse, route switch, etc). Stored in
// a ref so cancelAnimation() can cancel it — without that,
// back-to-back fitToCards calls would race: first call's settle
// would fire 370ms later and overwrite the second call's
// result, leaving the camera on the WRONG target while the
// minimap accurately reflects the broken state.
// Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target.
settleTimerRef.current = window.setTimeout(() => {
settleTimerRef.current = null;
const fresh = computeFitTarget(cardRects, maxZoom, minZoom);
@@ -1,16 +1,6 @@
import { useRef, useEffect } from 'react';
/**
* Attaches a native wheel listener to an overlay element that forwards scroll
* events to whatever scrollable content sits beneath it, while still letting
* the overlay capture pointer events (click / drag). Pinch-zoom (ctrl/meta +
* wheel) is left alone so the canvas zoom still works.
*
* Handles two cases:
* 1. Regular DOM scrollable containers uses `scrollBy` directly.
* 2. Electron `<webview>` elements executes JS inside the webview to scroll
* the element at the cursor position.
*/
/** Forwards wheel events through an overlay to the content beneath while keeping overlay click/drag; passes pinch-zoom. */
export function useOverlayScrollPassthrough(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
+2 -8
View File
@@ -279,13 +279,9 @@ const Modes: React.FC = () => {
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
boxShadow: c.shadow.sm,
// Promote each card to its own compositor layer so a
// hover-cross between cards in the grid only re-paints
// that one card's layer, not the whole grid.
// Own compositor layer per card so hover-cross re-paints one card, not the whole grid.
willChange: 'transform',
// Animate ONLY border-color on hover (cheap). Removing
// the box-shadow animation kills the per-frame CPU
// paint that fired on every hover-cross.
// Hover animates only border-color; box-shadow animation caused per-frame CPU paint.
'&:hover': { borderColor: mode.color },
transition: 'border-color 0.2s',
}}
@@ -408,7 +404,6 @@ const Modes: React.FC = () => {
maxRows={8}
/>
{/* Tools toggle + multi-select */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Checkbox
@@ -476,7 +471,6 @@ const Modes: React.FC = () => {
</Select>
</FormControl>
{/* Default Folder */}
<Box>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 0.75 }}>
Default Folder
+67 -258
View File
@@ -61,14 +61,7 @@ import { API_BASE } from '@/shared/config';
import PlanPicker from '@/app/components/PlanPicker';
import type { OpenSwarmPlan } from '@/shared/subscription/checkout';
// NOTE: a standalone CopilotAuthButton component used to live here, but it
// referenced `/agents/copilot/{models,start-auth,poll-auth,disconnect}`
// endpoints that never existed on the backend. GitHub Copilot now flows
// through 9Router's `github` OAuth under the generic SubscriptionCard path
// below, so the dead component was removed.
// Brand colors for provider group headers in the default-model picker.
// Mirrors the set used by the in-session ChatInput picker.
// Brand colors for provider group headers; mirrors ChatInput picker.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
openai: '#74AA9C',
@@ -90,14 +83,9 @@ const DEFAULT_MODEL_FALLBACK = [
{ value: 'haiku', label: 'Claude Haiku 4.5' },
];
// ── Subscription Provider Card ──
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
// We route "Gemini" through Antigravity OAuth same Google sign-in,
// but a different backend lane with a much higher preview quota than
// Gemini CLI's Code Assist free tier (which 429s after ~5 req/min).
// Users with Google AI Pro/Ultra automatically get "priority" limits
// on the Antigravity side; no extra action required from them.
// "Gemini" routes through Antigravity OAuth (same Google sign-in, higher quota than Gemini CLI's free tier).
{ id: 'antigravity', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
];
@@ -166,27 +154,18 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
);
};
// ── OpenSwarm Pro managed-subscription card ──
//
// Renders either a "Subscribe" CTA (when not connected) or a live usage +
// Manage/Disconnect card (when connection_mode === 'openswarm-pro'). All
// billing details come from /api/subscription/status at runtime — no
// pricing is hardcoded in this OSS repo.
/** Pro managed-subscription card: Subscribe CTA when disconnected, live usage + Manage/Disconnect when active. */
interface OpenSwarmProStatus {
connected: boolean;
connection_mode?: string;
plan?: string | null;
status?: string | null;
expires?: string | null;
// When the cloud reports the bearer as revoked (401) or the sub as past
// its grace period (402), backend clears local state and returns
// connected=false with a reason + last_plan so the UI can distinguish
// "your subscription ended" from "never subscribed."
// Backend returns reason + last_plan on 401/402 so UI distinguishes "subscription ended" from "never subscribed".
reason?: 'revoked' | 'expired' | null;
last_plan?: string | null;
usage?: {
// Live utilization from Claude's /api/oauth/usage — 0-100 percent of the
// shared pool subscription's 5h window consumed. Updated every ~30s.
// Live utilization (0-100%) of the shared pool subscription's 5h window; polled ~30s.
utilization?: number;
window_hours?: number;
window_ends_at?: number;
@@ -194,28 +173,17 @@ interface OpenSwarmProStatus {
} | null;
}
// Clamp an arbitrary plan name from the cloud to one of the three picker
// tiers. Falls back to pro_plus so the "recommended" default stays selected
// if the user's prior plan was hobby or an unknown value.
/** Clamp arbitrary cloud plan name to one of the three picker tiers; defaults to pro_plus. */
const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => {
if (plan === 'pro' || plan === 'pro_plus' || plan === 'ultra') return plan;
return 'pro_plus';
};
// ── Account card ──
//
// Shown at the top of the General tab. Three states:
// - Signed in (settings.user_id present): show email + signin method
// + Sign out button.
// - Paid user with no signed-in identity yet (bearer set, user_id null):
// same email shown, with a one-click "Link your account" CTA that
// fires a Google sign-in so analytics finally has a Person row.
// - Not signed in: small "Sign in to OpenSwarm" CTA that opens the gate.
/** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */
const AccountCard: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Narrow selectors: each subscribes to one primitive so unrelated
// settings edits (e.g. theme toggle) don't re-render this card.
// Narrow primitive selectors so unrelated settings edits (theme, etc.) don't re-render this card.
const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null);
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const signinMethod = useAppSelector((s) => s.settings.data.signin_method ?? null);
@@ -244,8 +212,7 @@ const AccountCard: React.FC = () => {
};
const onSignIn = () => {
// Pass local_port so the bearer-handoff page POSTs to the right
// backend port (Electron may bind anything in 8324..8424).
// Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424).
const localPort = (window as any).__OPENSWARM_PORT__ || 8324;
const params = new URLSearchParams({
install_id: installId,
@@ -257,7 +224,7 @@ const AccountCard: React.FC = () => {
else window.open(startUrl, '_blank');
};
// Not signed in at all (no bearer, no user_id) — small inline CTA.
// Not signed in at all (no bearer, no user_id); inline CTA.
if (!userId && !hasBearer) {
return (
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
@@ -341,9 +308,7 @@ const OpenSwarmProCard: React.FC = () => {
const dispatch = useAppDispatch();
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
// Track which usage thresholds we've already fired this session so the
// event doesn't spam every 30s while the counter hovers past
// the threshold. Reset implicitly on page unmount (settings close).
// Track fired usage thresholds so the event doesn't spam every 30s while counter hovers past the line.
const firedUsageThresholds = useRef<Set<number>>(new Set());
const refresh = useCallback(async () => {
@@ -351,7 +316,7 @@ const OpenSwarmProCard: React.FC = () => {
const r = await fetch(`${API_BASE}/subscription/status`);
if (r.ok) setStatus(await r.json());
} catch {
// silently ignore cloud might be offline
// silently ignore; cloud might be offline
}
}, []);
@@ -390,9 +355,7 @@ const OpenSwarmProCard: React.FC = () => {
}
};
// Fire subscription.usage_warning exactly once per threshold per session
// when utilization crosses 80% / 90%. Placed before the early return so
// the hook chain stays stable.
// Fire usage_warning once per threshold (80%, 90%); placed before the early return so hook chain stays stable.
useEffect(() => {
if (!status?.connected) return;
const rawPct = status.usage?.utilization ?? 0;
@@ -409,14 +372,12 @@ const OpenSwarmProCard: React.FC = () => {
}
}, [status]);
// Loading state — don't flash a CTA that disappears on first fetch.
// Don't flash a CTA that disappears on first fetch.
if (!status) return null;
const isConnected = !!status.connected;
const usage = status.usage;
// Pool utilization is live data from Claude's own /api/oauth/usage endpoint
// — a 0-100 percentage for the current 5h window of the subscription we're
// routing this user through.
// Pool utilization (0-100%) for the current 5h window of the routed subscription.
const pct = Math.max(0, Math.min(100, Math.round(usage?.utilization ?? 0)));
const windowEndsAt = usage?.window_ends_at;
@@ -474,21 +435,19 @@ const OpenSwarmProCard: React.FC = () => {
{isConnected ? (
<>
{/* Canceled-in-grace banner: user canceled in Stripe but still
inside the paid period. Show a clear "scheduled to cancel"
state so they're not surprised when access stops. */}
{/* Canceled-in-grace banner: canceled in Stripe but still inside paid period. */}
{status.status === 'canceled' && (
<Box sx={{
px: 1.2, py: 0.6, mb: 1.2, borderRadius: `${c.radius.sm}px`,
bgcolor: `${c.status.warning}15`, border: `1px solid ${c.status.warning}40`,
}}>
<Typography sx={{ fontSize: '0.72rem', color: c.status.warning, fontWeight: 500 }}>
Subscription canceled you still have access until {expiresLabel || 'the end of the period'}.
Subscription canceled. You still have access until {expiresLabel || 'the end of the period'}.
</Typography>
</Box>
)}
{/* Usage bar percentage only, no raw counts */}
{/* Usage bar; percentage only, no raw counts. */}
<Box sx={{ mb: 1.2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500 }}>
@@ -541,11 +500,7 @@ const OpenSwarmProCard: React.FC = () => {
</Button>
</Box>
{/* Canceled-in-grace: show the 3-tier picker inline so users can
pick a plan and resubscribe without clicking through a dialog.
Active (non-canceled) subscribers don't get the picker
mid-subscription plan changes go through Stripe's Customer
Portal via "Manage in Stripe". */}
{/* Canceled-in-grace: 3-tier picker inline for resubscribe; active subs use Stripe's portal instead. */}
{status.status === 'canceled' && (
<>
<Box sx={{ mt: 2.5, mb: 1.5, borderTop: `1px solid ${c.border.subtle}`, pt: 2 }}>
@@ -553,7 +508,7 @@ const OpenSwarmProCard: React.FC = () => {
Resubscribe to keep access past {expiresLabel || 'your end date'}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted }}>
Pick any plan below you can keep your current tier or switch.
Pick any plan below; you can keep your current tier or switch.
</Typography>
</Box>
<PlanPicker
@@ -565,9 +520,7 @@ const OpenSwarmProCard: React.FC = () => {
)}
</>
) : status.reason === 'expired' && status.last_plan ? (
// Truly expired: the bearer's subscription ended past its grace
// period. Show the 3-tier picker so the user can pick the same plan
// or upgrade; their prior plan is preselected visually.
// Expired: bearer's sub ended past grace; show picker with prior plan preselected.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro subscription has ended. Pick a plan to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
@@ -579,8 +532,7 @@ const OpenSwarmProCard: React.FC = () => {
/>
</>
) : status.reason === 'revoked' && status.last_plan ? (
// Token revoked but subscription existed — different CTA language
// so the user knows this isn't a billing issue.
// Token revoked but sub existed; CTA language differs so user knows this isn't billing.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro access token was revoked. Pick a plan to reconnect.
@@ -592,7 +544,7 @@ const OpenSwarmProCard: React.FC = () => {
/>
</>
) : (
// Genuine new user never had a subscription on this machine.
// Genuine new user; never had a subscription on this machine.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
One subscription, no Claude account needed. We handle everything behind the scenes.
@@ -607,12 +559,7 @@ const OpenSwarmProCard: React.FC = () => {
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// `status` and the polymorphic-shape `connections` array now live in the
// subscriptionsSlice. The onboarding gate (hasModelConnected in
// skipPredicates.ts) reads the same slice, so OAuth-driven connections
// unstick step 1 the moment they land — previously this card kept the
// status in local useState, which the onboarding predicate could never
// observe.
// status + connections live in subscriptionsSlice so the onboarding gate (hasModelConnected) sees OAuth connections immediately.
const status = useAppSelector((s) => s.subscriptions.status);
const connections = useAppSelector(selectSubscriptionConnections);
const [connecting, setConnecting] = useState<string | null>(null);
@@ -620,9 +567,7 @@ const SubscriptionCards: React.FC = () => {
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
// Thin wrapper around the slice thunk — returns the resolved status so
// call sites that inspect the payload (e.g. the initial-load retry loop
// checking `data?.running`) keep working unchanged.
// Thin wrapper that returns the resolved status so call sites inspecting the payload keep working.
const fetchStatus = useCallback(
async (opts?: { preserveTransient?: boolean }) => {
return dispatch(fetchSubscriptionStatus(opts)).unwrap();
@@ -630,18 +575,13 @@ const SubscriptionCards: React.FC = () => {
[dispatch],
);
// Refresh the chat model picker whenever subscription connection state
// changes — GET /agents/models intersects BUILTIN_MODELS with 9Router's
// live connected-provider set, so newly-connected subscriptions surface
// their models in the dropdown immediately.
// Refetch model picker after sub changes so newly-connected providers surface in the dropdown immediately.
const refreshPickerModels = () => { dispatch(fetchModels()); };
useEffect(() => {
let cancelled = false;
(async () => {
// Retry initial load a single transient probe miss on mount would
// otherwise wedge the UI on the loading spinner until the user closes
// and reopens Settings.
// Retry initial load; a single transient probe miss would otherwise wedge the spinner until reopen.
for (const delay of [0, 800, 2000]) {
if (cancelled) return;
if (delay) await new Promise(r => setTimeout(r, delay));
@@ -660,12 +600,11 @@ const SubscriptionCards: React.FC = () => {
);
const handleConnect = async (providerId: string) => {
// Cancel any previous attempt first
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
setConnecting(providerId);
setUserCode('');
// Small delay if retrying — avoids hitting Claude's rate limit
// Small delay on retry to avoid Claude's rate limit.
await new Promise(r => setTimeout(r, 500));
try {
@@ -679,22 +618,13 @@ const SubscriptionCards: React.FC = () => {
if (data.flow === 'device_code') {
const code = data.user_code || '';
setUserCode(code);
// Use a named window with features (not `_blank`) so Electron's
// setWindowOpenHandler sees `new-window` disposition and spawns a
// BrowserWindow popup — matching the Anthropic/Codex flow. With
// `_blank` the disposition becomes `foreground-tab` and our main.js
// handler routes it into the dashboard as a webview tab, which is
// what we saw for GitHub before this change.
//
// Keep a reference to the popup so we can auto-close it when the
// backend poll detects success, instead of leaving the user to
// dismiss the "Congratulations, you're all set" page manually.
// Named window + features so Electron's setWindowOpenHandler spawns a BrowserWindow popup, not a webview tab.
let devicePopup: Window | null = null;
if (data.verification_uri) {
devicePopup = window.open(data.verification_uri, 'oauth_connect', 'width=600,height=720');
}
// Shared cleanup whichever detection path fires first calls this.
// Shared cleanup; whichever detection path fires first calls this.
let stopped = false;
const onDeviceSuccess = () => {
if (stopped) return;
@@ -706,8 +636,7 @@ const SubscriptionCards: React.FC = () => {
setUserCode('');
fetchStatus();
refreshPickerModels();
// Auto-close popup 2s after success so user briefly sees the
// "Congratulations" page then it goes away automatically.
// Auto-close popup 2s after success so the "Congratulations" page is briefly visible then closes.
setTimeout(() => {
if (devicePopup && !devicePopup.closed) {
try { devicePopup.close(); } catch {}
@@ -715,8 +644,7 @@ const SubscriptionCards: React.FC = () => {
}, 2000);
};
// Path 1: device-code poll — asks backend to poll the provider's
// token endpoint via 9Router. Primary path when it works.
// Path 1: device-code poll via backend/9Router; primary path.
const pollOnce = async () => {
if (stopped) return;
try {
@@ -738,14 +666,10 @@ const SubscriptionCards: React.FC = () => {
console.warn(`[subscription-poll] ${providerId}: error:`, e);
}
};
pollOnce(); // immediate first attempt
pollOnce();
const devicePollTimer = setInterval(pollOnce, 5000);
// Path 2: status poller — checks 9Router's connection list
// directly every 2s. Catches the connection even if the
// device-code poll silently errors (e.g. 9Router 500 from
// postExchange or createProviderConnection). Same pattern
// the authorization_code flow already uses.
// Path 2: status poller every 2s; catches connection even when device-code poll silently errors.
const statusPollTimer = setInterval(async () => {
if (stopped) return;
try {
@@ -760,21 +684,15 @@ const SubscriptionCards: React.FC = () => {
setPollTimer(devicePollTimer);
// Detect when the user returns to the main window after
// interacting with the popup. In Electron, `popup.closed` is
// unreliable (the WindowProxy may not update when the child
// BrowserWindow is destroyed). Listening for `focus` on the
// main window is more robust — it fires when the user closes
// the popup, switches tabs, or clicks back on the app.
// Listen for main-window focus; Electron's popup.closed is unreliable when child BrowserWindow is destroyed.
let focusCheckDone = false;
const onFocus = async () => {
if (stopped || focusCheckDone) return;
focusCheckDone = true;
window.removeEventListener('focus', onFocus);
// Give 9Router 3 seconds to process the token exchange
// Give 9Router 3s to process the token exchange before the final status check.
await new Promise(r => setTimeout(r, 3000));
if (stopped) return;
// Final status check
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
@@ -784,7 +702,7 @@ const SubscriptionCards: React.FC = () => {
return;
}
} catch {}
// Connection not found reset card
// Connection not found; reset card.
stopped = true;
clearInterval(devicePollTimer);
clearInterval(statusPollTimer);
@@ -793,14 +711,12 @@ const SubscriptionCards: React.FC = () => {
setUserCode('');
fetchStatus();
};
// Delay registering the focus listener so the initial popup
// open doesn't immediately trigger it (opening a popup blurs
// then refocuses the parent in some cases).
// Delay focus listener; popup open can blur/refocus the parent and falsely trigger it.
setTimeout(() => {
if (!stopped) window.addEventListener('focus', onFocus);
}, 2000);
// 5-minute hard timeout clean up everything.
// 5-minute hard timeout; cleans up everything.
setTimeout(() => {
if (stopped) return;
stopped = true;
@@ -816,16 +732,7 @@ const SubscriptionCards: React.FC = () => {
}, 300000);
} else if (data.flow === 'authorization_code') {
// Some providers (currently Gemini/Google) enforce an anti-embedded-
// browser policy on their OAuth consent page that no amount of
// user-agent spoofing defeats. For those, the backend sets
// `use_external_browser: true` and we open the auth URL in the
// user's default browser via shell.openExternal. The callback then
// lands on OpenSwarm's own /api/subscriptions/callback endpoint
// (backend/main.py:138) which performs the exchange itself and
// shows a "Connected!" page. Detection happens via the status
// poller below — no postMessage handoff possible because the
// system browser has no window.opener relationship back to us.
// Gemini/Google block embedded browsers; backend sets use_external_browser and exchange happens server-side via /api/subscriptions/callback. Detect via status poller (no postMessage possible).
const useExternal = !!data.use_external_browser;
let popup: Window | null = null;
if (useExternal && (window as any).openswarm?.openExternal) {
@@ -834,8 +741,7 @@ const SubscriptionCards: React.FC = () => {
popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
}
// Status polling primary for external-browser flow, secondary
// (fast postMessage path below) for popup flow.
// Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster).
const statusPoller = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
@@ -853,8 +759,7 @@ const SubscriptionCards: React.FC = () => {
}, 2000);
setPollTimer(statusPoller);
// Shared exchange helper — called from whichever relay path
// (postMessage or Electron IPC) delivers the code first.
// Shared exchange helper invoked by whichever relay path delivers the code first.
let exchanged = false;
const runExchange = async (code: string, state?: string) => {
if (exchanged) return;
@@ -879,9 +784,7 @@ const SubscriptionCards: React.FC = () => {
refreshPickerModels();
};
// postMessage listener — works when the popup's /callback page can
// reach window.opener. Silently no-ops on Anthropic flows where the
// opener chain is severed by cross-origin redirects.
// postMessage listener; no-ops when cross-origin redirects sever window.opener.
const msgHandler = async (event: MessageEvent) => {
const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
@@ -889,10 +792,7 @@ const SubscriptionCards: React.FC = () => {
};
if (!useExternal) window.addEventListener('message', msgHandler);
// Electron IPC fallback main.js captures any child webContents
// navigating to localhost:20128/callback?code=... and forwards the
// parsed params here, so we exchange the code even when opener
// postMessage fails. No-op in non-Electron contexts.
// Electron IPC fallback; main.js forwards callback params so exchange works when opener postMessage fails.
let ipcUnsub: (() => void) | null = null;
const ow = (window as any).openswarm;
if (ow && typeof ow.onOauthCallback === 'function') {
@@ -901,14 +801,7 @@ const SubscriptionCards: React.FC = () => {
});
}
// Timeout: 3 minutes for popup flow (was 30s — too short for 2FA /
// slow networks, and on Windows postMessage from the callback popup
// can silently fail due to COOP / opener severing, leaving the only
// exit as this timeout firing mid-flow). 5 minutes for external-
// browser flow (user has to tab-switch, log in, consent — takes
// much longer in practice). The connecting-side poller (see the
// useEffect below `handleDisconnect`) is the authoritative safety
// net — this timeout just bounds the Connecting… indicator.
// 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit.
const timeoutMs = useExternal ? 300_000 : 180_000;
setTimeout(() => {
clearInterval(statusPoller);
@@ -933,8 +826,7 @@ const SubscriptionCards: React.FC = () => {
body: JSON.stringify({ provider: providerId }),
});
} catch {}
// Wait briefly for 9Router to process, then refresh both the
// subscription status and the chat model picker.
// Wait briefly for 9Router to process, then refresh subscription status + model picker.
setTimeout(() => {
fetchStatus();
refreshPickerModels();
@@ -942,18 +834,7 @@ const SubscriptionCards: React.FC = () => {
}, 500);
};
// Safety-net poller that runs whenever a connect attempt is in flight.
// The handleConnect flow's own statusPoller exits as soon as isActive is
// seen, and its 3-minute timeout unconditionally clears `connecting` —
// but on Windows the OAuth popup's postMessage path can fail silently
// (COOP severs opener, Defender interferes, etc.), so the ONLY way out
// of "Connecting…" becomes that timeout, which flips the card back to
// "Connect" even when the backend exchange succeeded. This separate
// poller watches the same status endpoint every 4s and clears the
// Connecting state the moment 9Router reports the provider isActive,
// whether that's via Method 1 (postMessage → frontend exchange), the
// 9Router callback page's Method 4 (server-side exchange), or the Codex
// listener's new server-side exchange.
// 4s safety-net poller while connecting; clears Connecting state whenever 9Router reports the provider isActive (handles Windows postMessage failures).
useEffect(() => {
if (!connecting) return;
let cancelled = false;
@@ -973,12 +854,10 @@ const SubscriptionCards: React.FC = () => {
};
const id = setInterval(tick, 4000);
return () => { cancelled = true; clearInterval(id); };
// refreshPickerModels is stable (no deps), fetchStatus isn't used here
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connecting]);
if (!status) {
// Initial loading — show skeleton cards
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
@@ -1031,7 +910,6 @@ const SubscriptionCards: React.FC = () => {
);
};
// ── Pixel Bar ──
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
@@ -1056,7 +934,6 @@ const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; pale
);
};
// ── Usage Stats Component ──
const UsageStats: React.FC = () => {
const c = useClaudeTokens();
const [stats, setStats] = useState<any>(null);
@@ -1069,7 +946,6 @@ const UsageStats: React.FC = () => {
}, []);
if (!stats) {
// Skeleton loading state
const skeletonPulse = {
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
@@ -1157,7 +1033,6 @@ const UsageStats: React.FC = () => {
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
// Pixel bar helper that passes tokens
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
<PixelBarOuter {...props} tokens={c} />
);
@@ -1176,7 +1051,6 @@ const UsageStats: React.FC = () => {
return (
<Box sx={{ mb: 2.5 }}>
{/* Row 1: Core metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Sessions</Typography>
@@ -1190,8 +1064,8 @@ const UsageStats: React.FC = () => {
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}>
{isSubscription
? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription`
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
? `${formatCost(stats.avg_cost_per_session)} avg, saved with your subscription`
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg, ${costSourceLabel}` : 'no cost data'}
</Typography>
</Box>
<Box sx={cardSx}>
@@ -1210,7 +1084,6 @@ const UsageStats: React.FC = () => {
</Box>
</Box>
{/* Row 2: Time + efficiency + tokens */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Run Time</Typography>
@@ -1238,15 +1111,13 @@ const UsageStats: React.FC = () => {
</Typography>
<Typography sx={subSx}>
{stats.total_prompt_tokens || stats.total_completion_tokens
? `${formatTokens(stats.total_prompt_tokens || 0)} in · ${formatTokens(stats.total_completion_tokens || 0)} out`
? `${formatTokens(stats.total_prompt_tokens || 0)} in, ${formatTokens(stats.total_completion_tokens || 0)} out`
: providerEntries.map(([p]) => p).join(', ') || 'none'}
</Typography>
</Box>
</Box>
{/* Model + Provider + Tool breakdown */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{/* Models & Providers */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
@@ -1265,7 +1136,6 @@ const UsageStats: React.FC = () => {
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
</Box>
{/* Tools */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
@@ -1292,7 +1162,7 @@ const UsageStats: React.FC = () => {
const API_KEY_STEPS = [
{
title: 'Open the Anthropic Console',
detail: 'Visit console.anthropic.com create a free account if you don\'t have one yet.',
detail: 'Visit console.anthropic.com; create a free account if you don\'t have one yet.',
link: 'https://console.anthropic.com',
},
{
@@ -1324,9 +1194,7 @@ const Settings: React.FC = () => {
const modesList = useMemo(() => Object.values(modes), [modes]);
// Model picker source — same state as the in-session ChatInput picker, so
// Settings shows exactly the models gated-in by the user's connected
// providers / subscriptions (OpenSwarm Pro, Anthropic, OpenAI, Google, ...).
// Model picker source matches the in-session ChatInput picker, so Settings reflects connected providers.
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
@@ -1355,11 +1223,7 @@ const Settings: React.FC = () => {
const installing = useAppSelector((s) => s.update.installing);
const initialTab = useAppSelector((s) => s.settings.initialTab);
// Persisted in-flight edits — survive modal close so the user can pop
// out to the dashboard / a doc / wherever and pick up where they left
// off without being prompted to "save or discard." Cleared on actual
// save (in the slice's updateSettings.fulfilled) or via the explicit
// "Discard changes" button.
// In-flight edits persisted to Redux so they survive modal close; cleared on save or explicit Discard.
const draft = useAppSelector((s) => s.settings.draft);
const draftTab = useAppSelector((s) => s.settings.draftTab);
const TAB_VALUES = ['general', 'models', 'usage', 'commands'] as const;
@@ -1371,20 +1235,13 @@ const Settings: React.FC = () => {
);
const [form, setForm] = useState<AppSettings>({ ...settings, ...(draft || {}) });
// Re-seed the form whenever the signed-in user changes. Without this,
// switching accounts left `form` holding the previous user's snapshot
// while Redux `settings` got reloaded for the new user, so the
// dirty-detector (form != settings) lit up the Save / Discard footer
// even though the user hadn't touched anything. Watching user_id +
// user_email handles sign-out, sign-in, and sign-in-as-different-user
// in one effect.
// Re-seed form on user change; otherwise the dirty detector falsely lights up Save/Discard.
useEffect(() => {
setForm({ ...settings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.user_id, settings.user_email]);
// When the modal opens with a requested tab (e.g., from the warning
// banner's "Configure models" link), switch to it.
// Switch to requested tab when modal opens (e.g. from the "Configure models" banner link).
useEffect(() => {
if (initialTab && (TAB_VALUES as readonly string[]).includes(initialTab)) {
setActiveTab(initialTab as SettingsTab);
@@ -1406,24 +1263,14 @@ const Settings: React.FC = () => {
}, [open, dispatch]);
useEffect(() => {
// On open, restore the user's last tab if they had unsaved edits;
// otherwise default to General. The caller's explicit initialTab
// (e.g. openSettingsModal('models') from the warning banner) wins
// over both — the separate initialTab effect above handles that.
// On open, restore the last tab from draft; explicit initialTab is handled by the effect above.
if (open && !initialTab) {
setActiveTab(isValidTab(draftTab) ? draftTab : 'general');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialTab]);
// Sync form to Redux settings on modal open / first load only — NOT on
// every settings change. Including `settings` in the deps causes any
// background dispatch that touches state.data (the SignInGate's 2s
// fetchSettings poll, the window-focus refetch in SettingsLoader, the
// updateSettings response, etc.) to wipe the user's in-flight edits
// mid-typing — that's the "save button flashes and the key disappears"
// report from issue #25. Spreads any preserved draft over settings so
// unsaved edits resurface after a close → reopen cycle.
// Sync form on modal open + first load only; including `settings` in deps wipes in-flight edits on background fetches (issue #25).
useEffect(() => {
if (open && loaded) {
setForm({ ...settings, ...(draft || {}) });
@@ -1431,11 +1278,7 @@ const Settings: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, loaded]);
// Persist in-flight edits to Redux so they survive modal close. Compares
// against `settings` rather than the previous draft so closing+reopening
// a clean form doesn't keep a phantom draft alive. Runs after every
// commit where form/activeTab changed; React batches keystrokes so the
// overhead is one dispatch per render, not per character.
// Persist in-flight edits to Redux; compares to `settings` so a clean reopen doesn't keep a phantom draft.
useEffect(() => {
if (!open || !loaded) return;
const dirty = JSON.stringify(form) !== JSON.stringify(settings);
@@ -1485,19 +1328,13 @@ const Settings: React.FC = () => {
setSaved(true);
};
// Closing Settings is now non-destructive — the draft persists in
// Redux so unsaved edits resurface on reopen. The old "Save or
// discard?" prompt was dropped because it interrupted the user every
// time they wanted to step out (e.g. to look up a value on the
// dashboard). Explicit discard lives on a button next to Save.
// Non-destructive close; draft persists in Redux. Explicit discard lives on its own button.
const handleRequestClose = useCallback(() => {
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}, [dispatch]);
// Explicit discard — fires from the "Discard changes" button. Wipes
// the draft so the form snaps back to saved settings; modal stays
// open so the user can verify the reset before closing.
// Explicit discard wipes the draft so form snaps back to saved settings; modal stays open for verification.
const handleConfirmDiscard = useCallback(() => {
setConfirmDiscard(false);
setForm({ ...settings });
@@ -1625,11 +1462,9 @@ const Settings: React.FC = () => {
{activeTab === 'general' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── Account ── */}
<Typography sx={sectionSx}>Account</Typography>
<AccountCard />
{/* ── Agent Defaults ── */}
<Typography sx={sectionSx}>Agent Defaults</Typography>
<Box sx={rowSx}>
@@ -1853,7 +1688,6 @@ const Settings: React.FC = () => {
/>
</Box>
{/* ── Interface ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
<Box sx={inlineRowSx}>
@@ -2023,7 +1857,6 @@ const Settings: React.FC = () => {
/>
</Box>
{/* ── Browser ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
<Box sx={rowLastSx}>
@@ -2050,7 +1883,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* ── Advanced ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
<Box sx={inlineRowSx}>
@@ -2083,7 +1915,6 @@ const Settings: React.FC = () => {
/>
</Box>
{/* About */}
<Typography sx={{ ...sectionSx, mt: 3 }}>About</Typography>
<Box sx={rowSx}>
@@ -2091,7 +1922,7 @@ const Settings: React.FC = () => {
<Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{appVersion ?? ''}
{appVersion ?? '-'}
</Typography>
</Box>
</Box>
@@ -2197,7 +2028,6 @@ const Settings: React.FC = () => {
<TrustedFilePatterns />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
@@ -2214,12 +2044,7 @@ const Settings: React.FC = () => {
try {
window.localStorage.removeItem('openswarm.onboarding.v2');
} catch { /* ignore */ }
// Soft reset via Redux — wipes completedSteps, opens the
// expanded panel at step 1. No reload needed; the slice's
// resetTour reducer handles everything in-memory and the
// localStorage-mirror middleware re-persists the new state.
dispatch(resetTour());
// Close the settings modal so the user sees the panel.
dispatch(closeSettingsModal());
onboardingBus.emit('settings:closed');
}}
@@ -2240,7 +2065,6 @@ const Settings: React.FC = () => {
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── OPENSWARM PRO (managed) ── */}
<Box data-onboarding="settings-pro-section" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
One Subscription, No Setup
@@ -2253,20 +2077,18 @@ const Settings: React.FC = () => {
<OpenSwarmProCard />
</Box>
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Box data-onboarding="settings-external-subs" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription, no API key needed, no extra cost.
</Typography>
<SubscriptionCards />
</Box>
{/* ── API KEYS ── */}
<Box data-onboarding="settings-api-keys" sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
@@ -2276,7 +2098,6 @@ const Settings: React.FC = () => {
Pay per use. Each key is stored locally on your device.
</Typography>
{/* Anthropic */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Anthropic</Typography>
@@ -2316,7 +2137,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* OpenAI */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>OpenAI</Typography>
@@ -2356,7 +2176,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* Google */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Google</Typography>
@@ -2396,7 +2215,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* OpenRouter */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>OpenRouter</Typography>
@@ -2436,7 +2254,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* Custom Providers — OpenAI-compatible endpoints (Ollama Cloud, Together AI, local Ollama, etc.) */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.25 }}>
<Typography sx={labelSx}>Custom Providers</Typography>
@@ -2462,7 +2279,7 @@ const Settings: React.FC = () => {
})()}
</Box>
<Typography sx={{ ...descSx, mb: 1.25 }}>
Add OpenAI-compatible endpoints Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions.
Add OpenAI-compatible endpoints (Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions).
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
@@ -2494,12 +2311,7 @@ const Settings: React.FC = () => {
const nameMissing = !cp.name?.trim();
const urlMissing = !cp.base_url?.trim();
const modelsMissing = filledModelCount === 0;
// api_key is optional local OpenAI-compatible servers
// (LM Studio, Ollama, llama.cpp, vLLM, etc.) usually run
// without auth. Backend substitutes a placeholder when
// blank so 9Router still gets a valid connection. Only
// hosted providers (Together, Groq, OpenRouter via Custom)
// need a real key.
// api_key is optional (local LM Studio/Ollama/llama.cpp/vLLM run without auth); hosted providers need a real key.
const isReady = !nameMissing && !urlMissing && !modelsMissing;
const dupeNameWithEarlier = list.findIndex((other, i) =>
i < idx && (other.name || '').trim().toLowerCase() === (cp.name || '').trim().toLowerCase() && (cp.name || '').trim() !== ''
@@ -2542,7 +2354,7 @@ const Settings: React.FC = () => {
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
bgcolor: isReady ? c.status.success : c.status.warning,
}} />
{isReady ? 'Ready' : `Incomplete · add ${missingLabels.join(', ')}`}
{isReady ? 'Ready' : `Incomplete, add ${missingLabels.join(', ')}`}
</Typography>
<IconButton
onClick={removeProvider}
@@ -2609,7 +2421,7 @@ const Settings: React.FC = () => {
</Typography>
{((cp.models || []).length === 0) ? (
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted, fontStyle: 'italic', px: 0.5 }}>
No models yet add the model IDs this endpoint serves.
No models yet, add the model IDs this endpoint serves.
</Typography>
) : (
(cp.models || []).map((m, mIdx) => (
@@ -2701,10 +2513,7 @@ const Settings: React.FC = () => {
{(activeTab === 'general' || activeTab === 'models') && (
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'space-between' }}>
{/* Left: explicit "Discard changes" only surfaces when there
are unsaved edits. Closing the modal no longer prompts; the
draft persists in Redux. This button is the only way to
actively wipe the draft. */}
{/* Left: explicit Discard; only path to wipe the persisted draft. */}
<Box>
{hasChanges && (
<Button
@@ -103,9 +103,7 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
[workspacePath],
);
// Honor Settings default_model + default_thinking_level. createDraftSession's
// hardcoded 'sonnet' / undefined-thinking would otherwise win and force every
// Skill Builder draft onto Sonnet + Auto thinking.
// Honor settings default_model + default_thinking_level; createDraftSession's hardcoded sonnet/auto would otherwise override.
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
@@ -116,7 +114,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
const wsId = `skill-ws-${Date.now().toString(36)}`;
setStableWorkspaceId(wsId);
// Resolve provider from the model registry (mirrors ChatInput.tsx provider map).
const PROVIDER_MAP: Record<string, string> = {
anthropic: 'anthropic',
'openswarm pro': 'anthropic',
@@ -168,13 +165,12 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
useEffect(() => {
if (draftCreated.current) return;
// Wait for settings + model registry so we don't snapshot stale 'sonnet'.
// Wait for settings + model registry; otherwise we'd snapshot stale sonnet.
if (!settingsLoaded || !modelsLoaded) return;
draftCreated.current = true;
initSession();
}, [initSession, settingsLoaded, modelsLoaded]);
// Poll workspace for updates
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastPollRef = useRef<string>('');
@@ -200,7 +196,7 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
setCurrentPreview(preview);
onSkillPreview(preview);
}
} catch { /* ignore polling errors */ }
} catch {}
}, [stableWorkspaceId, onSkillPreview]);
useEffect(() => {
@@ -212,7 +208,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
};
}, [expanded, pollWorkspace]);
// Final poll when agent finishes
const prevAgentActive = useRef(false);
useEffect(() => {
if (prevAgentActive.current && !isAgentActive) {
@@ -303,7 +298,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
overflow: 'hidden',
}}
>
{/* Left resize handle */}
<Box
onPointerDown={(e) => onResizeStart('left', e)}
onPointerMove={onResizeMove}
@@ -321,7 +315,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
'&:hover::after, &:active::after': { bgcolor: c.accent.primary },
}}
/>
{/* Top resize handle */}
<Box
onPointerDown={(e) => onResizeStart('top', e)}
onPointerMove={onResizeMove}
@@ -339,7 +332,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
'&:hover::after, &:active::after': { bgcolor: c.accent.primary },
}}
/>
{/* Top-left corner resize handle */}
<Box
onPointerDown={(e) => onResizeStart('corner', e)}
onPointerMove={onResizeMove}
@@ -351,7 +343,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
}}
/>
{/* Header */}
<Box
sx={{
display: 'flex',
@@ -420,7 +411,6 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
</Tooltip>
</Box>
{/* Chat area */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{effectiveSessionId ? (
<AgentChat
-18
View File
@@ -115,7 +115,6 @@ const Skills: React.FC = () => {
dispatch(fetchAllRegistrySkills());
}, [dispatch]);
// Group registry skills by category
const regGrouped = useMemo(() => {
const groups: Record<string, RegistrySkill[]> = {};
const q = searchFilter.toLowerCase();
@@ -139,7 +138,6 @@ const Skills: React.FC = () => {
const toggleCategory = (cat: string) =>
setCollapsedCats((p) => ({ ...p, [cat]: !p[cat] }));
// Selection handlers
const selectRegistry = (name: string) => {
setSelection({ type: 'registry', name });
dispatch(fetchSkillDetail(name));
@@ -149,13 +147,11 @@ const Skills: React.FC = () => {
setSelection({ type: 'local', id });
};
// Get active detail content
const selectedLocal: Skill | null =
selection?.type === 'local' ? items[selection.id] ?? null : null;
const selectedReg: RegistrySkillDetail | null =
selection?.type === 'registry' && regDetail?.name === selection.name ? regDetail : null;
// CRUD
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
@@ -212,7 +208,6 @@ const Skills: React.FC = () => {
return selection.type === 'local' && selection.id === key;
};
// ─── Content preview with raw/preview toggle ───
const ContentPreview: React.FC<{ content: string }> = ({ content }) => (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1, flexShrink: 0 }}>
@@ -278,7 +273,6 @@ const Skills: React.FC = () => {
</Box>
);
// ─── Sidebar row component ───
const SidebarRow: React.FC<{
label: string;
selected: boolean;
@@ -311,14 +305,12 @@ const Skills: React.FC = () => {
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden', bgcolor: c.bg.page, position: 'relative' }}>
{/* ─── Left Sidebar ─── */}
<Box
sx={{
width: SIDEBAR_W, minWidth: SIDEBAR_W, height: '100%', display: 'flex', flexDirection: 'column',
borderRight: `${c.border.width} solid ${c.border.subtle}`, bgcolor: 'transparent',
}}
>
{/* Sidebar header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, pt: 2, pb: 1 }}>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary }}>Skills</Typography>
<Box sx={{ display: 'flex', gap: 0.25 }}>
@@ -361,7 +353,6 @@ const Skills: React.FC = () => {
</Button>
</Box>
{/* Search input (toggled) */}
<Collapse in={searchFilter !== ''} timeout={0} unmountOnExit>
<Box sx={{ px: 1.5, pb: 1 }}>
<TextField
@@ -388,7 +379,6 @@ const Skills: React.FC = () => {
</Box>
</Collapse>
{/* Scrollable tree */}
<Box
sx={{
flex: 1, overflow: 'auto', px: 0.75, pb: 2,
@@ -396,7 +386,6 @@ const Skills: React.FC = () => {
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}
>
{/* My Skills (local) */}
{filteredLocal.length > 0 && (
<Box sx={{ mb: 1 }}>
<Box
@@ -431,7 +420,6 @@ const Skills: React.FC = () => {
</Box>
)}
{/* Registry categories */}
{(loading || regLoading) && regSkills.length === 0 && localSkills.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress size={22} sx={{ color: c.accent.primary }} />
@@ -481,7 +469,6 @@ const Skills: React.FC = () => {
</Box>
</Box>
{/* ─── Right Detail Panel ─── */}
<Box sx={{ flex: 1, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'transparent' }}>
{selection?.type === 'builder-preview' && builderPreview ? (
<Box sx={{ p: 4, pb: 3, maxWidth: 1100, display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
@@ -547,7 +534,6 @@ const Skills: React.FC = () => {
</Box>
) : selectedReg ? (
<Box sx={{ p: 4, pb: 3, maxWidth: 1100, display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
{/* Header row: name + actions */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5, flexShrink: 0 }}>
<Typography sx={{ fontSize: '1.4rem', fontWeight: 700, color: c.text.primary, fontFamily: c.font.sans }}>
{selectedReg.name}
@@ -612,7 +598,6 @@ const Skills: React.FC = () => {
) : null
) : selectedLocal ? (
<Box sx={{ p: 4, pb: 3, maxWidth: 1100, display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
{/* Header row: name + actions */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: '1.4rem', fontWeight: 700, color: c.text.primary, fontFamily: c.font.sans }}>
@@ -680,7 +665,6 @@ const Skills: React.FC = () => {
) : null}
</Box>
{/* ─── Create/Edit Dialog ─── */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
@@ -752,7 +736,6 @@ const Skills: React.FC = () => {
</DialogActions>
</Dialog>
{/* ─── Skill Builder Chat ─── */}
<SkillBuilderChat
onSkillPreview={handleBuilderPreview}
onSkillSaved={handleBuilderSaved}
@@ -760,7 +743,6 @@ const Skills: React.FC = () => {
onExpandedChange={setBuilderOpen}
/>
{/* ─── Snackbar ─── */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
+19 -77
View File
@@ -297,10 +297,6 @@ function serverToMcpConfig(srv: McpServer): Record<string, any> {
return {};
}
// ---------------------------------------------------------------------------
// ToolSection (reusable for Core / Extended built-in tool groups)
// ---------------------------------------------------------------------------
interface ToolSectionProps {
label: string;
icon: React.ReactElement;
@@ -464,9 +460,6 @@ const ToolSection: React.FC<ToolSectionProps> = ({
);
};
// ---------------------------------------------------------------------------
// Main Tools Page
// ---------------------------------------------------------------------------
const Tools: React.FC = () => {
const c = useClaudeTokens();
@@ -496,24 +489,15 @@ const Tools: React.FC = () => {
const [deferredSectionOpen, setDeferredSectionOpen] = useState(false);
const [customSectionOpen, setCustomSectionOpen] = useState(true);
// Dropdown menu
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
// Registry browser
const [registryOpen, setRegistryOpen] = useState(false);
const [regQuery, setRegQuery] = useState('');
const [regSort, setRegSort] = useState<'name' | 'stars'>('stars');
// Default 'curated' (Phase 2): the registry has thousands of community
// servers but most users only ever want the vetted set. Toggle to ''
// to see everything. The curated filter is purely client-side: the
// backend still returns the full list, we just hide the long tail.
// Default 'curated' hides the long tail; client-side filter, backend still returns the full list.
const [regSource, setRegSource] = useState<'' | 'community' | 'google' | 'curated'>('curated');
// Curated whitelist for the default registry view (Phase 2). Matches
// the per-server search alias map in main.py (mcp-meta) so the same
// 9 servers we recommend in MCPSearch are the ones the user sees by
// default in the Tools registry. Toggle to "All" / "Community" to
// browse the long tail.
// Curated whitelist matches the MCPSearch alias map in main.py (mcp-meta).
const CURATED_MCP_NAMES = useMemo(() => new Set([
'google-workspace', 'microsoft-365', 'slack', 'discord',
'notion', 'airtable', 'hubspot', 'reddit', 'youtube',
@@ -529,7 +513,6 @@ const Tools: React.FC = () => {
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: 'success' | 'error' }>({ open: false, message: '' });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// MCP config dialog state
const [mcpConfigOpen, setMcpConfigOpen] = useState(false);
const [mcpConfigServer, setMcpConfigServer] = useState<McpServer | null>(null);
const [mcpAuthType, setMcpAuthType] = useState<'none' | 'env_vars'>('none');
@@ -537,21 +520,17 @@ const Tools: React.FC = () => {
const [mcpConfigJson, setMcpConfigJson] = useState('');
const [mcpConfigError, setMcpConfigError] = useState('');
// Expanded MCP tool permissions state
const [expandedToolId, setExpandedToolId] = useState<string | null>(null);
const [discovering, setDiscovering] = useState(false);
// Integration toggle state
const [integrationLoading, setIntegrationLoading] = useState<Record<string, boolean>>({});
// Device code login dialog state (M365)
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState<string | null>(null);
const [deviceCode, setDeviceCode] = useState('');
const [deviceCodeUrl, setDeviceCodeUrl] = useState('');
const [deviceCodeStatus, setDeviceCodeStatus] = useState<'loading' | 'awaiting' | 'connected' | 'error'>('loading');
// Integration credentials dialog state
const [credDialogOpen, setCredDialogOpen] = useState(false);
const [credDialogToolId, setCredDialogToolId] = useState<string | null>(null);
const [credDialogIntegration, setCredDialogIntegration] = useState<Integration | null>(null);
@@ -572,12 +551,12 @@ const Tools: React.FC = () => {
} else if (existing && existing.enabled === false) {
await dispatch(updateTool({ id: existing.id, enabled: true }));
if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') {
setSnackbar({ open: true, message: `Enabled ${integration.name} connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name} re-discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering actions…` });
const discoverResult = await dispatch(discoverTools(existing.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
} else {
const detail = (discoverResult as any).error?.message || 'discovery failed';
setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' });
@@ -596,15 +575,15 @@ const Tools: React.FC = () => {
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
setSnackbar({ open: true, message: `Enabled ${integration.name} connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name} discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering actions…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| `discovery failed is ${integration.mcp_config.command || 'the server'} installed?`;
|| `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`;
setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' });
}
}
@@ -622,7 +601,7 @@ const Tools: React.FC = () => {
if (discoverTools.fulfilled.match(result)) {
setSnackbar({ open: true, message: 'Actions discovered successfully' });
} else {
const detail = (result as any).error?.message || 'Discovery failed is the MCP server running?';
const detail = (result as any).error?.message || 'Discovery failed; is the MCP server running?';
setSnackbar({ open: true, message: detail, severity: 'error' });
}
} finally {
@@ -693,7 +672,6 @@ const Tools: React.FC = () => {
await dispatch(updateBuiltinPermissions(perms));
};
// Built-in tool grouping
const BROWSER_CATEGORIES = new Set(['browser_delegation', 'browser_action']);
const coreTools = useMemo(() => builtinTools.filter((bt) => !bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]);
const deferredTools = useMemo(() => builtinTools.filter((bt) => bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]);
@@ -740,8 +718,6 @@ const Tools: React.FC = () => {
const toggleCategory = (cat: string) => setCollapsedCategories((p) => ({ ...p, [cat]: !p[cat] }));
const toggleBuiltinExpand = (name: string) => setExpandedBuiltin((p) => (p === name ? null : name));
// --------------- Dropdown handlers ---------------
const handleMenuOpen = (e: React.MouseEvent<HTMLElement>) => setMenuAnchor(e.currentTarget);
const handleMenuClose = () => setMenuAnchor(null);
@@ -763,8 +739,6 @@ const Tools: React.FC = () => {
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' }));
};
// --------------- Tool CRUD ---------------
const openEdit = (tool: ToolDefinition) => {
setEditingId(tool.id);
setForm({ name: tool.name, description: tool.description, command: tool.command });
@@ -779,11 +753,7 @@ const Tools: React.FC = () => {
const handleDelete = async (id: string) => { await dispatch(deleteTool(id)); };
// --------------- Registry browser ---------------
// Translate the UI's "curated" pseudo-source into "" for the backend
// (which doesn't know that filter) — the curated whitelist is applied
// client-side via the regServers memo above.
// Translate UI "curated" pseudo-source to "" for the backend; the whitelist is applied client-side.
const _backendSource = (s: '' | 'community' | 'google' | 'curated'): '' | 'community' | 'google' =>
s === 'curated' ? '' : s;
@@ -865,7 +835,7 @@ const Tools: React.FC = () => {
auth_type: 'oauth2',
auth_status: 'configured',
}));
setSnackbar({ open: true, message: `Installed "${f.name}" click "Connect Google" to authorize` });
setSnackbar({ open: true, message: `Installed "${f.name}", click "Connect Google" to authorize` });
} else if (hasConfig && mcpConfig.type === 'stdio') {
const result = await dispatch(createTool({
name: f.name,
@@ -878,13 +848,13 @@ const Tools: React.FC = () => {
}));
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
setSnackbar({ open: true, message: `Installed "${f.name}" discovering actions…` });
setSnackbar({ open: true, message: `Installed "${f.name}", discovering actions…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${f.name} ready actions discovered` });
setSnackbar({ open: true, message: `${f.name} ready, actions discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| 'discovery failed the MCP server may need setup first';
|| 'discovery failed; the MCP server may need setup first';
setSnackbar({ open: true, message: `${f.name}: ${detail}`, severity: 'error' });
}
}
@@ -934,7 +904,7 @@ const Tools: React.FC = () => {
}
}, 1000);
} else {
setSnackbar({ open: true, message: 'OAuth failed check that OAuth credentials are set in backend .env', severity: 'error' });
setSnackbar({ open: true, message: 'OAuth failed; check that OAuth credentials are set in backend .env', severity: 'error' });
}
};
@@ -953,10 +923,8 @@ const Tools: React.FC = () => {
setDeviceCodeUrl(url);
setDeviceCodeStatus('awaiting');
// Auto-open Microsoft login in a popup
window.open(url, 'm365-login', 'width=500,height=700,left=200,top=100');
// Poll for completion
const poll = setInterval(async () => {
const statusResult = await dispatch(pollDeviceCodeStatus(toolId));
if (pollDeviceCodeStatus.fulfilled.match(statusResult)) {
@@ -976,7 +944,6 @@ const Tools: React.FC = () => {
}
}, 2000);
// Stop polling after 5 minutes
setTimeout(() => clearInterval(poll), 300000);
} else {
setDeviceCodeStatus('error');
@@ -1059,9 +1026,7 @@ const Tools: React.FC = () => {
const handleDisconnectIntegration = async (toolId: string, integration: Integration) => {
if (integration.authType === 'oauth2') {
// Revoke the token on Google's side (fire-and-forget)
fetch(`${API_BASE}/tools/${toolId}/oauth/disconnect`, { method: 'POST' }).catch(() => {});
// Clear OAuth state via the existing update endpoint
const result = await dispatch(updateTool({
id: toolId,
oauth_tokens: {},
@@ -1086,7 +1051,6 @@ const Tools: React.FC = () => {
return (
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Action Library</Typography>
@@ -1120,7 +1084,6 @@ const Tools: React.FC = () => {
</Box>
</Box>
{/* Built-in Tool Sets */}
<Box sx={{ mb: 3 }}>
<Box
onClick={() => setBuiltinSectionOpen((v) => !v)}
@@ -1134,17 +1097,14 @@ const Tools: React.FC = () => {
<Collapse in={builtinSectionOpen} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{/* Core Tools */}
{coreTools.length > 0 && (
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(coreTools, v)} />
)}
{/* Extended Tools */}
{deferredTools.length > 0 && (
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(deferredTools, v)} />
)}
{/* Apps */}
{outputs.length > 0 && (
<Card sx={{ bgcolor: c.bg.surface, border: `1px solid ${viewsSectionOpen && viewsSectionEnabled ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: c.accent.primary, boxShadow: '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
@@ -1226,7 +1186,6 @@ const Tools: React.FC = () => {
</Card>
)}
{/* Browser */}
{browserTools.length > 0 && (
<Card sx={{ bgcolor: c.bg.surface, border: `1px solid ${browserSectionOpen && browserSectionEnabled ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: c.accent.primary, boxShadow: '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
@@ -1275,7 +1234,6 @@ const Tools: React.FC = () => {
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{/* Delegation group */}
{browserDelegationTools.length > 0 && (() => {
const delegationPolicies = browserDelegationTools.map((t) => builtinPermissions[t.name] || 'always_allow');
const groupPolicy = delegationPolicies.every((p) => p === 'always_allow') ? 'always_allow'
@@ -1323,7 +1281,6 @@ const Tools: React.FC = () => {
);
})()}
{/* Browser Actions group */}
{browserActionTools.length > 0 && (() => {
const actionPolicies = browserActionTools.map((t) => builtinPermissions[t.name] || 'always_allow');
const groupPolicy = actionPolicies.every((p) => p === 'always_allow') ? 'always_allow'
@@ -1380,7 +1337,6 @@ const Tools: React.FC = () => {
</Collapse>
</Box>
{/* Custom Tool Sets */}
<Box sx={{ mb: 2 }}>
<Box onClick={() => setCustomSectionOpen((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}>
{customSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
@@ -1516,9 +1472,7 @@ const Tools: React.FC = () => {
(tool.command || '').toLowerCase().includes('youtube');
const isSubredditsForReddit =
isReddit && /subreddit/i.test(serviceName);
// For YouTube the permission marker lands on the FIRST
// service group (whatever it's called) since the YouTube
// integration doesn't have a specific drill-down.
// YouTube marker lands on the first service group since YouTube has no drill-down.
const showPermissionMarker =
isSubredditsForReddit || (isYoutube && isFirstGroup);
@@ -1632,13 +1586,7 @@ const Tools: React.FC = () => {
const isDisabled = tool.enabled === false;
// Defensive Reddit detection: ig.id is the canonical key but
// depends on Integration metadata matching tool.name exactly.
// If a tool was installed under a different name shape (e.g.
// legacy install, manual MCP add), the lookup fails and
// ig?.id === 'reddit' is false. Fall back to tool.name and
// tool.command lowercase checks so the data-onboarding hooks
// still attach and onboarding click_target waits can resolve.
// Defensive Reddit detection so onboarding hooks still attach when ig.id lookup fails (legacy/manual installs).
const isReddit =
ig?.id === 'reddit' ||
tool.name?.toLowerCase() === 'reddit' ||
@@ -1881,7 +1829,6 @@ const Tools: React.FC = () => {
</Collapse>
</Box>
{/* Create/Edit Tool Dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` } }}>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600 }}>{editingId ? 'Edit Tool' : 'New Tool'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
@@ -1895,7 +1842,6 @@ const Tools: React.FC = () => {
</DialogActions>
</Dialog>
{/* Registry Browser Dialog */}
<Dialog
open={registryOpen}
onClose={() => setRegistryOpen(false)}
@@ -2205,7 +2151,6 @@ const Tools: React.FC = () => {
</DialogActions>
</Dialog>
{/* MCP Config Dialog */}
<Dialog
open={mcpConfigOpen}
onClose={() => setMcpConfigOpen(false)}
@@ -2308,7 +2253,6 @@ const Tools: React.FC = () => {
</DialogActions>
</Dialog>
{/* Microsoft 365 Device Code Login Dialog */}
<Dialog
open={deviceCodeDialogOpen}
onClose={() => { if (deviceCodeStatus !== 'loading') setDeviceCodeDialogOpen(false); }}
@@ -2367,7 +2311,6 @@ const Tools: React.FC = () => {
</DialogActions>
</Dialog>
{/* Integration Credentials Dialog */}
<Dialog
open={credDialogOpen}
onClose={() => setCredDialogOpen(false)}
@@ -2389,7 +2332,7 @@ const Tools: React.FC = () => {
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
{credDialogIntegration?.id === 'slack' ? (
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.5, bgcolor: c.bg.secondary, px: 2, py: 1.5, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
Click <strong>Sign in with Slack</strong> below a Slack window will open. Sign in normally and the window will close automatically once you reach your workspace.
Click <strong>Sign in with Slack</strong> below; a Slack window will open. Sign in normally and the window will close automatically once you reach your workspace.
</Typography>
) : (
<>
@@ -2428,7 +2371,6 @@ const Tools: React.FC = () => {
</DialogActions>
</Dialog>
{/* Install success snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
@@ -66,7 +66,6 @@ const CodeEditor: React.FC<Props> = ({ value, onChange, language, placeholder })
view.destroy();
viewRef.current = null;
};
// Recreate the editor when extensions change (language/theme switch)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [extensions]);
+8 -13
View File
@@ -6,12 +6,10 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export type TerminalSource = 'frontend' | 'backend' | 'runtime';
export interface TerminalLine {
// Monotonic id so React keys are stable even if the same text is
// logged twice in a row (which is common — heartbeat tickers, etc.).
// Monotonic id so React keys stay stable when the same text logs twice (heartbeat tickers etc.).
id: number;
source: TerminalSource;
// For frontend lines, level is the console method (log/warn/error/info/debug).
// For backend lines, it's "stdout" / "stderr". For runtime status lines, "info".
// frontend: console method; backend: "stdout"/"stderr"; runtime: "info".
level: string;
text: string;
}
@@ -21,12 +19,12 @@ interface Props {
}
const PREFIX_COLORS: Record<TerminalSource, string> = {
frontend: '#60a5fa', // blue — comes from the user-facing app
backend: '#34d399', // green — comes from backend.py stdout
runtime: '#a78bfa', // purple — comes from our runtime manager itself
frontend: '#60a5fa', // blue: user app
backend: '#34d399', // green: backend.py stdout
runtime: '#a78bfa', // purple: runtime manager
};
const STDERR_COLOR = '#f87171'; // red — surfaces stderr / console.error / runtime errors
const STDERR_COLOR = '#f87171'; // red: stderr, console.error, runtime errors
function colorForLine(line: TerminalLine): string {
if (line.source === 'backend' && line.level === 'stderr') return STDERR_COLOR;
@@ -46,10 +44,7 @@ const TerminalPanel: React.FC<Props> = ({ lines }) => {
const containerRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);
// Auto-scroll to the bottom on new lines, but only if the user wasn't
// mid-scroll-up reading older content. The threshold (32px) tolerates
// sub-pixel rounding and the brief mid-update positions react-virtual
// and friends produce.
// Stick to bottom on new lines unless the user scrolled up; 32px tolerates sub-pixel rounding.
const onScroll = () => {
const el = containerRef.current;
if (!el) return;
@@ -89,7 +84,7 @@ const TerminalPanel: React.FC<Props> = ({ lines }) => {
>
{lines.length === 0 ? (
<Typography sx={{ color: '#8b949e', fontFamily: c.font.mono, fontSize: '0.78rem', fontStyle: 'italic' }}>
Waiting for output backend stdout/stderr and the running app's console.log will show here.
Waiting for output... backend stdout/stderr and the running app's console.log will show here.
</Typography>
) : (
lines.map((line) => (
+3 -13
View File
@@ -29,16 +29,9 @@ const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun }) => {
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
overflow: 'hidden',
// Promote each card to its own compositor layer so a hover-
// cross between cards in the grid only re-paints that one
// card's layer, not the entire grid. Same fix we landed on
// the dashboard AgentCard.
// Own compositor layer so hover paint stays scoped (same fix as dashboard AgentCard).
willChange: 'transform',
// Animate ONLY transform on hover (composited on the GPU).
// Previously this also animated box-shadow + border-color via
// `transition: all`, which forces per-frame CPU paint for the
// shadow blur on every card the user hovers across. Border
// color is layout-free and ~free to paint, so we keep that.
// Animate only transform + border-color; `transition: all` triggers per-frame CPU paint for box-shadow blur.
transition: 'transform 0.15s ease, border-color 0.15s ease',
'&:hover': {
borderColor: c.border.strong,
@@ -160,8 +153,5 @@ const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun }) => {
);
};
// Memoize so re-renders of the parent (Views.tsx) don't re-render every
// card. The callback props are inline arrow functions from the parent so
// they change every render, but the equality check below treats them as
// stable when `output` identity is unchanged.
// Custom equality: parent re-renders pass new inline callbacks every time, so key on output identity only.
export default React.memo(ViewCard, (prev, next) => prev.output === next.output);
+51 -312
View File
@@ -45,22 +45,7 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus';
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
// ---- App-Builder cold-start placeholder -----------------------------------
//
// On a fresh app the workspace needs npm/vite + (optionally) uvicorn to
// finish booting before the preview iframe has anything to render. That's
// ~60-90s the first time. The old placeholder was a static spinner + a
// jargon-heavy "Cold start can take 60-90 seconds. Check the Terminal tab
// to follow npm install + Vite startup output..." — non-devs read that as
// Cold-start placeholder. Shows the same Bayer-dither pixel-blast
// shader as the webapp_template's `index.html` splash and its placeholder
// `pages/index.tsx`, so the visual is continuous across all three
// phases (desktop pre-Vite, inline-HTML splash, React-rendered home).
// Earlier revisions used a progress bar + rotating "Brewing..." copy;
// the bar was always fake (no real progress signal) and the rotation
// felt more anxious than calming. A single continuous animation reads
// as "background brewing, nothing to worry about" without lying about
// progress.
// Cold-start splash: same Bayer-dither shader as the template's index.html for visual continuity across boot phases.
const InstallPlaceholder: React.FC = () => {
const c = useClaudeTokens();
return (
@@ -108,13 +93,7 @@ const InstallPlaceholder: React.FC = () => {
);
};
// File-tree noise defaults. VSCode's equivalent `files.exclude` hides
// the same set (plus a few more) — we apply by basename anywhere in
// the path so e.g. `frontend/node_modules` and `frontend/dist` are
// both filtered out. User can flip `showHidden` to bypass. Anything
// the agent legitimately writes lives in `src/`, `public/`,
// `backend/`, `package.json`, `vite.config.ts`, `.env`, `README.md`
// — none of those collide with this set.
// File-tree noise: filtered by basename anywhere in the path; `showHidden` bypasses.
const HIDDEN_PATH_SEGMENTS = new Set<string>([
'node_modules',
'.vite-cache',
@@ -125,12 +104,7 @@ const HIDDEN_PATH_SEGMENTS = new Set<string>([
'__pycache__',
'.venv',
]);
// Workspace state poll cadence. While the agent is actively writing
// files we want a snappy 2s so the file tree / code panes stay in
// sync. Once the agent goes idle there's no reason to keep hammering
// `/api/outputs/workspace/<ws>` every 2s — bump to 15s. The
// agent-status effect snaps a one-shot poll on every active→idle
// transition, so we don't miss the FINAL file write at quiescence.
// Poll fast while agent is writing; slow while idle. A one-shot poll fires on active->idle transition to catch the last write.
const POLL_INTERVAL_ACTIVE_MS = 2000;
const POLL_INTERVAL_IDLE_MS = 15000;
@@ -326,41 +300,20 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
const [activeTab, setActiveTab] = useState(TAB_PREVIEW);
const [activeFile, setActiveFile] = useState('index.html');
// When false, HIDDEN_PATH_SEGMENTS get filtered out of the file tree.
// Persisted to the workspace's localStorage so toggle survives reload.
const [showHidden, setShowHidden] = useState(false);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Skip preview reloads when nothing the user can SEE changed.
// The iframe renders index.html; if a save only touched SKILL.md or
// other non-rendered files, there's no point reloading the iframe —
// the visible content is identical and we'd just flash the empty
// "Ready" placeholder during the reload-blank-moment. Tracking the
// last reloaded snapshot of index.html lets us short-circuit those.
// Combined with the trailing-edge debounce below, the iframe only
// reloads when (a) index.html actually changed AND (b) the agent
// has stopped writing for >600ms — usually 0-1 reloads per generation.
// Only reload the iframe when index.html actually changed AND the agent has paused writing for 600ms; saves to SKILL.md etc don't flash the preview.
const previewReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastReloadedIndexHtmlRef = useRef<string>(initialFiles['index.html'] ?? '');
const PREVIEW_RELOAD_DEBOUNCE_MS = 600;
const savingRef = useRef(false);
// Terminal pane state. Persistent app-backend stdout/stderr arrives via
// the runtime WS; the running app's console.log/warn/error arrives via
// ipc-message from the webview-preload bridge. Both feed into a single
// chronological buffer here so the user sees interleaved [FRONTEND] /
// [BACKEND] / [RUNTIME] lines.
// Runtime WS feeds backend stdout/stderr; webview-preload ipc-message feeds frontend console.* into the same chronological buffer.
const [terminalLines, setTerminalLines] = useState<TerminalLine[]>([]);
const terminalLineIdRef = useRef(0);
const TERMINAL_BUFFER_CAP = 5000; // trim FIFO past this so we don't grow unbounded
const previewRef = useRef<ViewPreviewHandle>(null);
// `iframePainted` is true once the embedded app's first navigation
// has fired its `load` event PLUS a 300 ms grace for React/Vue/etc.
// to commit its first paint. Used to keep the cold-start placeholder
// overlaid on top of the iframe until the user-visible content is
// actually on screen — otherwise vite reporting "ready" → placeholder
// unmount → iframe-still-loading-its-bundle reads as a grey flash.
// Resets whenever the serve URL changes (new app, vite restart) so
// each load has its own placeholder lifecycle.
// True ~300ms after iframe `load`; keeps placeholder up until SPA actually paints, resets on URL change.
const [iframePainted, setIframePainted] = useState(false);
const SIDEBAR_MIN = 280;
@@ -393,17 +346,11 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
const [initialDraftId, setInitialDraftId] = useState<string | null>(null);
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
// Reuse the workspace_id stored on the Output if present so we don't seed a
// fresh folder every time the editor remounts (which would orphan the agent's
// in-progress edits and lose chat continuity). Only mint a new id for first-
// time outputs that don't yet have one persisted.
// Reuse the Output's workspace_id across remounts so we don't orphan agent edits or chat history.
const [stableWorkspaceId] = useState(() => output?.workspace_id || `ws-${Date.now().toString(36)}`);
const draftCreated = useRef(false);
// Honor the user's Settings default_model + default_thinking_level.
// Without this, createDraftSession's hardcoded 'sonnet' / undefined-thinking
// fallbacks win and App Builder always opens on Sonnet + Auto thinking
// regardless of what the user picked in Settings.
// Honor Settings default_model + default_thinking_level (else createDraftSession's hardcoded 'sonnet' wins).
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
@@ -412,14 +359,11 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
useEffect(() => {
if (draftCreated.current) return;
// Wait for settings + model registry before seeding the draft, otherwise
// we'd snapshot the Redux initial 'sonnet' default and ignore the user's pick.
// Wait for settings + models else we'd snapshot Redux's initial 'sonnet' over the user's choice.
if (!settingsLoaded || !modelsLoaded) return;
draftCreated.current = true;
// Resolve provider from the model registry. Group names mirror the
// provider map in ChatInput.tsx (Anthropic / OpenSwarm Pro → 'anthropic',
// Google → 'gemini', xAI/Meta/etc → 'openrouter').
// Provider map mirrors ChatInput.tsx grouping.
const PROVIDER_MAP: Record<string, string> = {
anthropic: 'anthropic',
'openswarm pro': 'anthropic',
@@ -441,12 +385,8 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
}
(async () => {
// Reattach branch: this Output already has a session + workspace from a
// prior visit. Skip seeding (would clobber any in-progress edits the
// agent made) and skip createDraftSession (would orphan the live session).
// Just resolve the workspace path and tell AgentChat which session to bind to.
// Reattach: Output has an existing session + workspace; skip seeding/draft so we don't clobber agent state.
if (output?.session_id && output?.workspace_id) {
// Resolve the workspace path first (best-effort; chat works without it).
let resolvedWorkspacePath: string | null = null;
try {
const res = await fetch(`${WORKSPACE_API}/${output.workspace_id}`);
@@ -459,32 +399,21 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
}
} catch { /* path is best-effort */ }
// Verify the persisted session still exists on the backend before
// binding to it. The id can become stale (backend data wiped,
// sessions cleared, different OpenSwarm install) — without this
// check we'd hand AgentChat a non-existent id and the chat pane
// would be stuck on "Initializing agent…" forever. On 200 we
// bind; on 404 we fall through to seed a fresh draft session
// attached to the same workspace (preserves app code on disk;
// only the chat history is lost — acceptable trade).
// Verify session still exists; ids go stale across reinstalls/data wipes and AgentChat would hang on "Initializing agent..."
let sessionStillExists = false;
try {
const sr = await fetch(`${API_BASE}/agents/sessions/${output.session_id}`);
sessionStillExists = sr.ok;
} catch { /* network blip treat as missing */ }
} catch { /* network blip, treat as missing */ }
if (sessionStillExists) {
// Pull the latest session state from the backend so the chat
// catches up on anything the agent did while the user was on
// another tab.
// Catch up on anything the agent did while we were on another tab.
dispatch(fetchSession(output.session_id));
setInitialDraftId(output.session_id);
return;
}
// Stale linkage. Clear it from the Output so future opens skip
// the 404 round-trip, then fall through to createDraftSession
// with the same workspace.
// Stale link: clear it so future opens skip the 404 round-trip, then fall through.
if (output.id) {
try {
await dispatch(updateOutput({ id: output.id, session_id: null })).unwrap();
@@ -519,42 +448,12 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
});
const data = await res.json();
setWorkspacePath(data.path);
// Backend creates an Output record at seed time for
// webapp_template workspaces (workspace_id wired up, name
// "Untitled App"). Adopt that id NOW so:
// 1. The Apps sidebar refresh below shows the in-progress app
// immediately — users who navigate away can find it again.
// 2. Later autosaves take the updateOutput branch (using
// `output?.id ?? createdIdRef.current`) instead of trying
// to recreate.
// Old flat-mode seeds don't return output_id; that path keeps
// its previous behavior (create-on-first-autosave).
// Adopt the backend-minted output_id so the Apps sidebar shows the app and later autosaves hit updateOutput.
if (typeof data?.output_id === 'string' && data.output_id) {
createdIdRef.current = data.output_id;
setCreatedId(data.output_id);
// Refresh the Apps list so the new app shows up in the sidebar
// before the user navigates away from /apps/new.
dispatch(fetchOutputs());
// Replace /apps/new in the URL with /apps/{output_id} so a
// reload (or back-button return) lands back on the same
// workspace instead of spinning up yet another fresh seed.
//
// CRITICAL: bypass React Router (`navigate()`) and use the
// raw `window.history.replaceState` instead. Views.tsx
// renders <ViewEditor key={editingOutput?.id ?? 'new'} />
// — a React-Router-driven path change from /apps/new to
// /apps/<id> would flip that key, React would UNMOUNT this
// ViewEditor and MOUNT a new one, AgentChat's chat-input DOM
// node would get a new identity, and the onboarding wizard's
// type_into would silently fire into the now-detached old
// input (no text lands, hasContent stays false, send button
// never renders, wizard burns 15 s on waitForSelector and
// throws into the recovery popup). window.history.replaceState
// changes the URL without triggering Views' re-render, so
// ViewEditor stays mounted and the chat-input the wizard
// already found is the same one it types into. On hard
// reload React Router reads the live URL fresh, so the
// back-button / reload behavior is preserved.
// CRITICAL: use window.history.replaceState, NOT navigate(). Views.tsx keys ViewEditor on output id; React Router would unmount/remount, the onboarding wizard would type into a detached input and burn 15s on waitForSelector.
if (window.location.hash.includes('/apps/new')) {
const newHash = window.location.hash.replace(
'/apps/new',
@@ -563,10 +462,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
try {
window.history.replaceState(null, '', newHash);
} catch {
// Fallback to React Router nav if the history API rejects
// (extremely unusual; mostly defensive). Accepts the
// remount cost in that edge case rather than dropping
// the URL update entirely.
// Defensive: history API rejection accepts the remount cost rather than dropping the URL update.
navigate(`/apps/${data.output_id}`, { replace: true });
}
}
@@ -593,17 +489,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
})();
}, [dispatch, output, stableWorkspaceId, settingsLoaded, modelsLoaded, defaultModel, defaultThinkingLevel, modelsByProvider]);
// Resolve our bound session id strictly through our own pointers:
// 1. initialDraftId is the draft we created OR the real id reattached
// from output.session_id.
// 2. If the draft was launched in the meantime, the real id lives in
// draftLaunchMap — promote to that.
// We deliberately do NOT fall back to state.agents.activeSessionId here.
// activeSessionId is a global pointer that any dashboard click, child
// chat, or sibling App Builder can clobber, so falling back to it bled
// unrelated agents' chats into the App Builder while a session was
// still loading (e.g. JobFinder's transcript showing inside the
// Chatbot app builder).
// Resolve via our own pointers only; falling back to activeSessionId bled unrelated agents' chats into the wrong builder.
const launchedFromDraft = useAppSelector((state) =>
initialDraftId ? state.agents.draftLaunchMap[initialDraftId] : undefined,
);
@@ -615,9 +501,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
return null;
});
// Once a draft has been replaced by a real launched session, promote
// initialDraftId so subsequent renders bypass the map lookup and we
// stay on the real id even if draftLaunchMap is later cleaned up.
// Promote draftId to the real session id so we survive draftLaunchMap cleanup.
useEffect(() => {
if (launchedFromDraft && initialDraftId && launchedFromDraft !== initialDraftId) {
setInitialDraftId(launchedFromDraft);
@@ -679,15 +563,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
if (!workspaceId) return;
const interval = isAgentActive ? POLL_INTERVAL_ACTIVE_MS : POLL_INTERVAL_IDLE_MS;
// Visibility-gate the poll loop. When the App Builder tab is
// hidden (user navigated to Dashboard / Skills / Actions / a
// different Electron window), there's no UI to update — but the
// interval would otherwise keep hitting `/api/outputs/workspace`
// every 2s, blocking the foreground backend's other endpoints.
// On `hidden` we clear the timer entirely; on `visible` we fire
// one immediate poll (to catch up on whatever the agent wrote
// while we were away) then restart the interval. Reuses the
// existing isAgentActive-driven cadence.
// Visibility-gate the poll so a hidden tab doesn't keep hammering /api/outputs/workspace and starving the foreground.
const startPoll = () => {
if (pollRef.current) return;
pollWorkspace();
@@ -720,9 +596,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
prevAgentActive.current = isAgentActive;
}, [isAgentActive, workspaceId, pollWorkspace]);
// Hold the latest session status in a ref so the unmount cleanup can read it
// at teardown time (the cleanup closure would otherwise capture a stale value
// from when the effect first ran).
// Ref so the unmount cleanup reads the live status, not a stale closure value.
const sessionStatusRef = useRef<string | null>(null);
sessionStatusRef.current = agentStatus;
const isLaunchedRef = useRef(false);
@@ -730,22 +604,14 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
useEffect(() => {
return () => {
// Only garbage-collect drafts the user abandoned without launching. Once
// a session is launched, the agent runs on the backend independent of
// the frontend — leave the Redux entry alive so navigating away doesn't
// wipe in-progress work or chat history.
// GC only abandoned drafts; launched sessions live on the backend independently.
if (initialDraftId && sessionStatusRef.current === 'draft' && !isLaunchedRef.current) {
dispatch(removeDraftSession(initialDraftId));
}
};
}, [initialDraftId, dispatch]);
// Persist session_id + workspace_id onto the saved Output the moment the
// session goes from draft to launched. Without this, reopening the App later
// would have no way to find its in-progress session and would seed a fresh one.
// Use `createdId` (state) not `createdIdRef.current` so the effect re-fires
// after autosave creates the Output for a brand-new app. `output` prop is a
// parent snapshot that doesn't refresh, so we dedup via a ref.
// Persist session_id + workspace_id on draft->launched so reopens find the in-progress session; deduped via ref since `output` prop is a stale snapshot.
const persistedLinkageRef = useRef<string | null>(null);
useEffect(() => {
const eid = output?.id ?? createdId;
@@ -815,18 +681,11 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
savedId = created.id;
createdIdRef.current = savedId;
setCreatedId(savedId);
// First successful create = the App Builder agent finished
// generating an app. Step 8's "wait for app to land" listens for
// this. Subsequent saves don't fire — only the initial creation
// matters for onboarding.
// Step 8 onboarding waits on this; only fires on first create.
onboardingBus.emit('app:generation_done');
}
savedRef.current = true;
// Trailing-edge debounce + content-changed gate. Only triggers
// a real iframe reload when the agent has gone quiet AND the
// file the iframe actually renders (index.html) changed since
// the last reload. Eliminates the "Ready" empty-state flash
// entirely for non-rendered file writes (SKILL.md, etc).
// Reload iframe only when agent has paused AND index.html actually changed; skips "Ready" flash on non-rendered writes.
if (previewReloadTimerRef.current) {
clearTimeout(previewReloadTimerRef.current);
}
@@ -853,8 +712,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
level,
text,
});
// FIFO trim — keep the tail. Past TERMINAL_BUFFER_CAP, the head is
// ancient and not what the user is reading.
// FIFO trim: drop the ancient head past TERMINAL_BUFFER_CAP.
if (next.length > TERMINAL_BUFFER_CAP) {
return next.slice(next.length - TERMINAL_BUFFER_CAP);
}
@@ -866,11 +724,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
appendTerminalLine('frontend', level, text);
}, [appendTerminalLine]);
// Reload button context menu — left-click is a soft reload (reloads
// the webview only); right-click opens this menu, which adds a Hard
// Reload that also restarts the persistent backend subprocess.
// Useful when backend.py has a Python-level error you can only clear
// by stopping and re-spawning the process.
// Right-click adds Hard Reload (also restarts the backend subprocess for Python-error recovery).
const [reloadMenuAnchor, setReloadMenuAnchor] = useState<HTMLElement | null>(null);
const handleHardReload = useCallback(async () => {
setReloadMenuAnchor(null);
@@ -888,59 +742,19 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
previewRef.current?.reload();
}, [workspaceId]);
// Persistent backend lifecycle. Once we know the workspaceId:
// 1. POST /runtime/start so the workspace's runtime (bash run.sh
// for new-mode webapp-template workspaces; python backend.py for
// legacy flat workspaces) gets spawned.
// 2. Open the runtime WS to stream [BACKEND]/[RUNTIME] stdout/stderr
// into the Terminal pane AND surface the frontend_url for new-
// mode workspaces (so the preview pane can point at Vite's dev
// server instead of our legacy /serve/ endpoint).
// 3. On unmount, POST /runtime/stop. Multiple editors on the same
// workspace share the runtime (ref-counted server-side); detach
// is a no-op until the last subscriber leaves.
// Runtime lifecycle: /runtime/start, stream stdout/stderr + frontend_url via WS, /runtime/stop on unmount (ref-counted server-side).
const runtimeWsRef = useRef<WebSocket | null>(null);
// Where the preview pane should point. New-mode workspaces report a
// frontend_url via runtime:status; until it arrives (or for old-mode
// workspaces that never set it), we fall back to the legacy
// /api/outputs/workspace/{ws}/serve/ endpoint below.
// New-mode workspaces report frontend_url via runtime:status; fall back to the legacy /serve/ endpoint until it arrives.
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
// Track new-mode separately so the preview pane can show a
// "Installing dependencies…" placeholder while Vite is still booting
// instead of trying to load the legacy /serve/index.html path (which
// 404s — new-mode workspaces have no `index.html` at root, only
// `frontend/index.html` reachable via Vite).
// Track new-mode separately so we can show "Installing..." instead of loading the 404ing legacy /serve/index.html.
const [isNewModeRuntime, setIsNewModeRuntime] = useState(false);
// Latched flag: true once the user has visited a tab that needs the
// runtime (Preview / Terminal) for the current workspace. Only goes
// true → reset to false ONLY when the workspace changes. Tab flips
// back to Code DON'T reset it, so the lifecycle effect that depends
// on it doesn't tear down on Preview → Code → Preview. This used to
// be a ref (`runtimeStartedRef`) but refs don't trigger re-renders,
// and the lifecycle effect couldn't react to the flip without
// running activeTab through its dep array — which is exactly what
// caused the cleanup-on-tab-switch bug.
// Latched: only flips true (resets on workspace change). Must be state, not a ref, so the lifecycle effect's deps react to it without depending on activeTab (caused tear-down on tab switch).
const [runtimeShouldRun, setRuntimeShouldRun] = useState(false);
useEffect(() => {
setRuntimeShouldRun(false);
}, [workspaceId]);
// Split into two effects so a tab switch never tears down the
// running workspace. The original single useEffect included
// `activeTab` in its dep array — the early `if (runtimeStartedRef…
// return` skipped re-starting, but the CLEANUP from the prior run
// still executed, POSTing /runtime/stop and clearing both
// frontendUrl and isNewModeRuntime to null. With those reset, the
// showInstallPlaceholder gate (`isNewModeRuntime && !frontendUrl`)
// collapsed to false, workspaceServeUrl fell back to the legacy
// /api/outputs/workspace/<ws>/serve/index.html path which 404s for
// new-mode workspaces → the iframe rendered the raw
// `{"detail":"File not found"}` JSON. The fix is to drive Effect A
// (lifecycle) off a STATE flag that ONLY ever flips true → never
// back to false on tab change, and to let Effect B (one-shot
// trigger) watch activeTab. State flips are visible to React's
// dep checker; ref mutations aren't, so a state flag is the right
// primitive here.
// Two effects so tab switches don't tear down the runtime; depending on activeTab here caused cleanup-on-switch which 404'd the iframe.
useEffect(() => {
if (!workspaceId || !runtimeShouldRun) return;
let cancelled = false;
@@ -999,12 +813,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
};
}, [workspaceId, runtimeShouldRun, appendTerminalLine]);
// Effect B — one-shot trigger. The first time the user lands on a
// tab that actually needs the runtime (Preview or Terminal), flip
// runtimeShouldRun true. Effect A picks that up and fires
// /runtime/start. After the flip, switching back to Code does NOT
// flip it false — the runtime stays warm because the LRU pool
// keeps it alive and tab flips should be free.
// One-shot trigger: first visit to Preview/Terminal flips runtimeShouldRun true; never flips back.
useEffect(() => {
if (!workspaceId) return;
if (runtimeShouldRun) return;
@@ -1013,48 +822,24 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
setRuntimeShouldRun(true);
}, [workspaceId, activeTab, runtimeShouldRun, TAB_PREVIEW, TAB_TERMINAL]);
// Preview URL: prefer the new-mode Vite dev server when the runtime
// reports one; otherwise fall back to the legacy serve endpoint.
// For new-mode workspaces where Vite hasn't bound yet (npm install
// still running), `workspaceServeUrl` is undefined and the preview
// pane renders the "Installing dependencies…" placeholder below
// instead of falling back to the legacy /serve/ path (which 404s —
// new-mode workspaces have no `index.html` at root).
// Prefer the Vite dev server URL; fall back to legacy /serve/. New-mode pre-Vite renders the install placeholder (legacy URL 404s).
const showInstallPlaceholder = isNewModeRuntime && !frontendUrl;
const workspaceServeUrl = showInstallPlaceholder
? undefined
: (frontendUrl ?? (workspaceId ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined));
// Reset the paint-tracking flag when the iframe's source URL changes —
// each new URL is a fresh load and the placeholder needs to stay up
// until THAT URL's content paints, not whatever paint happened last
// time.
// Reset paint tracking on URL change so the placeholder stays up for the new load.
useEffect(() => {
setIframePainted(false);
}, [workspaceServeUrl]);
// Called by ViewPreview when its iframe (or webview) fires the `load`
// event for a real serveUrl. We delay flipping the painted flag by
// 300 ms, the `load` event fires when the HTML doc has loaded but
// SPA bundles (React/Vue/etc) need a beat to mount and paint, so an
// immediate flip would re-introduce the grey flash we're trying to
// kill.
// 300ms after iframe `load` because SPA bundles need a beat to mount, otherwise the grey flash returns.
const onIframeContentLoad = useCallback(() => {
const t = window.setTimeout(() => setIframePainted(true), 300);
return () => window.clearTimeout(t);
}, []);
// Placeholder lifecycle. Keep the InstallPlaceholder mounted across
// transient gate flips (runtime WS reporting is_new_mode:true with
// a still-null frontend_url, then null→URL a beat later) so the
// PixelBlast canvas keeps rendering continuously. If we let React
// unmount the placeholder Box each time the gate flips false, the
// GL context is rebuilt on the next flip and the user reads the
// fresh canvas as the animation restarting from t=0 (even though
// PIXEL_BLAST_EPOCH keeps uTime mathematically continuous, the
// user can't perceive that without a reference frame). Always
// fade via opacity, then unmount only after the 400 ms fade-out
// has actually completed.
// Keep PixelBlast mounted across transient gate flips; unmounting rebuilds the GL context and the user reads it as the animation restarting.
const placeholderVisible = showInstallPlaceholder || !iframePainted;
const [placeholderMounted, setPlaceholderMounted] = useState(placeholderVisible);
useEffect(() => {
@@ -1066,16 +851,9 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
return () => window.clearTimeout(t);
}, [placeholderVisible]);
// VSCode-style default `files.exclude`: hide build/install noise from
// the file tree by default. With the symlinked node_modules + vite's
// per-workspace .vite-cache, an unfiltered tree renders hundreds of
// MUI/icons chunks the agent + user have no reason to look at. They
// can still be opened via the Workspace folder in Finder if needed.
// Single-source-of-truth predicate so the file list, tree, and
// open-file routing all agree on what counts as visible.
// VSCode-style files.exclude predicate; single source of truth for list/tree/open-file routing.
const isHiddenPath = useCallback((p: string): boolean => {
if (showHidden) return false;
// Treat exact basenames + any nested occurrence as hidden.
const segments = p.split('/');
for (const seg of segments) {
if (HIDDEN_PATH_SEGMENTS.has(seg)) return true;
@@ -1185,14 +963,9 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
return (
<ElementSelectionProvider>
<Box sx={{ height: '100%', display: 'flex', overflow: 'hidden' }}>
{/* Left panel AgentChat */}
{/* Left panel: AgentChat */}
<Box
// data-onboarding-scope="app-builder" — the AC's per-agent
// selector resolver prefers this scope when it's mounted, so
// step 8's chat-input / chat-send-button / type_into all
// resolve inside the App Builder's AgentChat instance instead
// of falling through to whatever chat-input was last in DOM
// order (which led to AC typing into nothing visible).
// Scope name pins onboarding step 8's selectors to this AgentChat instance, not whatever was last in DOM order.
data-onboarding-scope="app-builder"
sx={{
width: sidebarWidth,
@@ -1220,10 +993,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
sx={{
// 6px hit-target, but overlapped onto the seam via negative
// margins so the handle doesn't occupy its own visible column
// (would read as chunky dead space). Same pattern as the
// global sidebar handle in AppShell.
// 6px hit-target overlapped via negative margins so it doesn't take a visible column (same as AppShell sidebar handle).
width: 6,
marginLeft: '-3px',
marginRight: '-3px',
@@ -1240,10 +1010,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
left: '50%',
transform: 'translateX(-50%)',
width: 1,
// Truly invisible at rest. The earlier subtle border-color
// line crossed the chat-header's own borderBottom and the
// right panel's borderBottom and read as a thick T-shaped
// grey junction. Handle only appears on hover/active.
// Invisible at rest; otherwise the border line forms a T-junction with the chat header.
bgcolor: 'transparent',
transition: 'width 0.15s, background-color 0.15s',
},
@@ -1265,10 +1032,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
px: 1.5,
py: 1,
bgcolor: c.bg.secondary,
// Hairline below the header so the meta strip (App name +
// Description) reads as its own band against the tabs row
// underneath, instead of merging into one chunky block of
// bg.secondary. Half-pixel keeps it whisper-light.
// Hairline separates the meta strip from the tabs row.
borderBottom: `0.5px solid ${c.border.subtle}`,
flexShrink: 0,
minHeight: 48,
@@ -1303,10 +1067,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
'& .MuiInput-input': {
fontSize: '0.82rem',
color: c.text.muted,
// Match the App-name input's vertical padding so the
// two inputs occupy the same internal height — without
// this the baselines drift by a couple pixels even
// with `alignItems: baseline` on the parent.
// Match the App-name input's padding so baselines align.
py: 0.25,
},
'& .MuiInput-underline:before': { borderColor: 'transparent' },
@@ -1321,10 +1082,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
sx={{
display: 'flex',
alignItems: 'center',
// Drop the hard borderBottom — let bg-color step between
// this tab strip and the content below carry the
// separation. Claude Design's pane edges are nearly
// invisible, which is what makes them read as airy.
// No borderBottom; bg-color step carries the separation.
bgcolor: c.bg.secondary,
flexShrink: 0,
px: 1.25,
@@ -1334,9 +1092,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
// Hide the underline indicator entirely — we're showing
// active state via background-fill pills instead, matching
// Claude Design's "Recent / Your designs" toggle pattern.
// No underline indicator; active state is bg-fill pills.
TabIndicatorProps={{ sx: { display: 'none' } }}
sx={{
flex: 1,
@@ -1349,13 +1105,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
minWidth: 'auto',
fontSize: '0.8rem',
textTransform: 'none',
// Keep ONE weight across selected and unselected. Earlier
// revisions bumped from 500 to 600 on selection, which
// widened the glyphs by a couple px per character. With
// three pills sharing a flex row, that width change
// rippled outward and the whole tab strip visibly shifted
// on every click. Differentiating via bgcolor + text color
// alone keeps the layout pixel-locked.
// One weight across states; bumping on select widens glyphs and shifts the whole row.
fontWeight: 600,
color: c.text.tertiary,
px: 1.75,
@@ -1378,7 +1128,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
<Tab disableRipple label="Terminal" value={TAB_TERMINAL} />
</Tabs>
{activeTab === TAB_PREVIEW && (
<Tooltip title="Reload preview · right-click for Hard Reload">
<Tooltip title="Reload preview; right-click for Hard Reload">
<IconButton
size="small"
onClick={() => previewRef.current?.reload()}
@@ -1417,13 +1167,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{activeTab === TAB_PREVIEW && (
<Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Iframe always renders the moment we HAVE a URL even
while the install placeholder is still on top so the
embedded app's first paint completes BEFORE we fade
the placeholder out. Otherwise the user sees a
~1-2 s window of blank/grey "iframe loaded but app
hasn't painted yet" once `showInstallPlaceholder`
flips false. */}
{/* Render iframe under the placeholder so its first paint completes before we fade the placeholder out. */}
{(workspaceServeUrl || !showInstallPlaceholder) && (
<ViewPreview
ref={previewRef}
@@ -1435,12 +1179,7 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
onContentLoad={onIframeContentLoad}
/>
)}
{/* Overlay placeholder until the iframe has painted +
a 300 ms grace for the SPA's first React commit.
Stays mounted across transient gate flips (see
placeholderMounted lifecycle above) so the
PixelBlast canvas keeps running continuously;
fades in and out via opacity, never via mount. */}
{/* Placeholder fades via opacity (never unmounts) so the PixelBlast canvas runs continuously. */}
{placeholderMounted && (
<Box
sx={{
+20 -97
View File
@@ -7,11 +7,7 @@ import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// We render apps in a <webview> when running inside the Electron shell so
// they escape iframe restrictions (popups, mic/camera, WebAuthn,
// cross-origin fetch with cookies). Outside Electron — webpack-dev-server
// in the browser, jest, etc. — `<webview>` is a no-op element, so we fall
// back to the iframe path. Same detection BrowserCard uses.
// In Electron use <webview> to escape iframe restrictions (popups, mic/camera, WebAuthn, cookied fetch); outside Electron fall back to iframe.
const isElectron = navigator.userAgent.includes('Electron');
export interface ViewPreviewHandle {
@@ -26,16 +22,9 @@ interface Props {
inputData: Record<string, any>;
backendResult?: Record<string, any> | null;
style?: React.CSSProperties;
/** Forwarded for each `console.{log,warn,error,info,debug}` inside the
* running app (captured by webview-preload.js ipc-message). Only
* fires in the webview path iframes have no comparable channel. */
/** Forwarded per console.* call in the running app (webview path only; iframes have no equivalent channel). */
onConsoleMessage?: (level: string, text: string) => void;
/** Fires once the iframe/webview has finished its first navigation +
* load event for a given serveUrl. Lets parents (ViewEditor) keep
* the cold-start placeholder visible until the embedded app has
* actually painted, instead of unmounting the placeholder the moment
* vite reports "ready" (which leaves a 1-2 s window where the
* iframe has a URL but no content visible grey flash). */
/** Fires once the embedded app has actually painted, so cold-start placeholders don't unmount during the vite-ready to first-paint gap. */
onContentLoad?: () => void;
}
@@ -78,20 +67,11 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
const ctx = useElementSelection();
// Match the iframe/webview's BG to the OpenSwarm host's theme during
// load. Previously hardcoded '#fff', which on a dark OpenSwarm host
// produced a jarring white flash for the 60-90 s between vite spawn
// and first paint, then ANOTHER flash to the same white when the
// app reattached. Using the host's page color means the loading
// state visually blends with the chrome around it — no flashes
// until the app's own theme paints over it.
// Bg matches host theme so the 60-90s vite-boot gap doesn't flash white on dark hosts.
const _hostTokens = useClaudeTokens();
const _hostBg = _hostTokens.bg.page;
const [reloadKey, setReloadKey] = useState(0);
// Track auth token in state so the iframe URL is rebuilt the moment the
// token IPC roundtrip resolves. Without this, the first render runs while
// _authTokenCache is still '' and the iframe loads a tokenless URL → 401
// → the JSON error renders inside the preview pane.
// Track in state so the iframe URL rebuilds the moment the token IPC roundtrip resolves (else first render 401s with a JSON body).
const [authToken, setAuthToken] = useState(() => getAuthToken());
useEffect(() => {
if (authToken) return;
@@ -104,22 +84,14 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
const iframeSrc = useMemo(() => {
if (!serveUrl) return undefined;
// Don't ship a tokenless URL — the backend auth middleware would 401 and
// the iframe would render the JSON error. Wait for the token to load.
// Wait for the token; tokenless URL 401s and the iframe would render the JSON error body.
if (!authToken) return undefined;
const dataParam = encodeDataParam(inputData, backendResult);
const sep = serveUrl.includes('?') ? '&' : '?';
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}&token=${encodeURIComponent(authToken)}`;
}, [serveUrl, inputData, backendResult, reloadKey, authToken]);
// Pause the iframe when the Electron window is hidden (minimized, occluded,
// user switched to a different desktop space). Vite's HMR client keeps a
// WS heartbeat open + the app's rAF loops keep running otherwise — pure
// wasted CPU since nobody can see the result. Swap to about:blank, which
// destroys the previous document and closes its HMR connection cleanly.
// Only applies to URL-mode (vite dev server). Srcdoc apps stay put — they
// don't run HMR and pausing them would silently wipe arbitrary in-memory
// user state.
// When the window is hidden, swap URL-mode iframes to about:blank to kill HMR + rAF CPU; srcdoc apps stay put so user in-memory state isn't wiped.
const [windowHidden, setWindowHidden] = useState(
() => typeof document !== 'undefined' && document.visibilityState === 'hidden',
);
@@ -134,9 +106,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return windowHidden ? 'about:blank' : iframeSrc;
}, [iframeSrc, windowHidden]);
// "Restoring preview" overlay covers the gap between window-restore and
// the iframe finishing its second navigation back to the dev server. Set
// on hidden→visible transition; cleared by iframe load (or 5 s safety).
// "Restoring preview..." overlay covers the window-restore to iframe-reload gap; cleared by load or 5s safety.
const [restoring, setRestoring] = useState(false);
const wasHiddenRef = useRef(windowHidden);
useEffect(() => {
@@ -151,14 +121,8 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}, [windowHidden, iframeSrc]);
const handleNavigationLoad = useCallback(() => {
// load fires for both the about:blank pause-step AND the restored URL —
// only the latter should clear the overlay.
// load fires for both about:blank pause and real URL; only the latter counts.
if (!windowHidden) setRestoring(false);
// Notify parent that an actual URL just finished loading. Skip the
// about:blank pauses (those happen while the OpenSwarm window is
// hidden) — those aren't user-visible content paints. The parent
// (ViewEditor) uses this to know when its install-placeholder can
// safely fade away.
if (!windowHidden && onContentLoad) {
onContentLoad();
}
@@ -169,19 +133,10 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return buildSrcdoc(frontendCode, inputData, backendResult);
}, [serveUrl, frontendCode, inputData, backendResult]);
// Use webview when (a) we're in Electron and (b) we have a real serveUrl
// to navigate to. Inline srcdoc still goes through the iframe path: a
// webview's only inline option is `data:text/html,...` which the Electron
// sandbox treats as a null/opaque origin, breaking localStorage and
// same-origin fetch for the rendered app.
// Webview only when we have a real serveUrl; data:text/html for srcdoc breaks same-origin in the Electron sandbox.
const useWebview = isElectron && !!iframeSrc;
// Wire the iframe element into the element-selection context only when
// we're actually rendering an iframe. A <webview>'s document lives in
// a separate renderer process — its contentDocument is null from the
// host page, so useIframeElementSelector's overlay/listener injection
// can't reach it. Element selection on in-Electron previews is a known
// regression of the webview swap.
// Webview's contentDocument is null from the host (separate renderer process); element selection skips it (known regression).
useEffect(() => {
if (useWebview) return;
if (ctx && iframeRef.current) {
@@ -189,18 +144,13 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}
}, [ctx, frontendCode, serveUrl, useWebview]);
// Selector hook keys off iframeRef.current. When webview is mounted
// instead, no <iframe> is rendered, so iframeRef.current stays null and
// setupSelection() bails — same effect as an explicit gate.
// Selector hook no-ops in webview mode because iframeRef stays null.
useIframeElementSelector(iframeRef);
useImperativeHandle(ref, () => ({
reload: () => {
if (useWebview) {
// Bumping reloadKey changes _v= in the URL, which React threads
// back into the webview's `src` prop and re-navigates. Belt-and-
// suspenders: also call reload() on the element in case React
// skipped the re-render (e.g. reloadKey was already pending).
// Bumping reloadKey re-navigates via src change; also call reload() as belt-and-suspenders.
setReloadKey(k => k + 1);
webviewRef.current?.reload?.();
} else if (serveUrl) {
@@ -221,10 +171,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}
}, [srcdoc, useWebview]);
// Subscribe to the webview's ipc-message channel so the App Builder can
// surface [FRONTEND] logs from inside the running app. The preload
// script wraps console.* and emits 'webview-console' events; we forward
// each one up via `onConsoleMessage`. Iframe path doesn't use this.
// Forward webview-console events (preload wraps console.*) to onConsoleMessage; iframe path has no equivalent.
useEffect(() => {
if (!useWebview || !onConsoleMessage) return;
const wv = webviewRef.current;
@@ -241,18 +188,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
};
}, [useWebview, onConsoleMessage, iframeSrc]);
// Webviews don't surface a React-style `onLoad` prop; subscribe to the
// Electron-specific `did-finish-load` event to clear the restoring
// overlay after the about:blank→iframeSrc transition completes.
//
// Also subscribe to `did-fail-load` and retry. When the runtime WS
// reports a frontend_url before Vite has actually bound to its
// port, the first navigation hits ERR_CONNECTION_REFUSED and only
// did-fail-load fires (never did-finish-load), which would leave
// the parent's cold-start placeholder stuck on forever. The retry
// re-issues the navigation with exponential backoff (500 ms → 5 s)
// until Vite is actually serving, at which point did-finish-load
// fires and the overlay can fade cleanly.
// Webviews use did-finish-load instead of onLoad; did-fail-load retries with 500ms to 5s backoff (Vite may not have bound yet when frontend_url arrives).
useEffect(() => {
if (!useWebview) return;
const wv = webviewRef.current;
@@ -274,10 +210,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
handleNavigationLoad();
};
const onFail = (e: any) => {
// Sub-resource failures inside the embedded app (a missing
// favicon, a 404 image) also fire did-fail-load, so guard on
// isMainFrame. User-initiated aborts (ERR_ABORTED = -3) also
// surface here and shouldn't trigger a retry loop.
// Guard on isMainFrame (subresource 404s fire too) and ERR_ABORTED (user-cancel).
if (e && e.isMainFrame === false) return;
if (e && e.errorCode === -3) return;
if (retryTimer != null) return;
@@ -350,13 +283,10 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
{useWebview ? (
<webview
ref={(el: any) => { webviewRef.current = el; }}
// Stable key so React swaps src in place rather than remounting
// — preserves the prior frame's pixels through reload, same
// pattern as the iframe path.
// Stable key so src swaps in place (keeps prior pixels through reload).
key="url-mode-webview"
src={effectiveSrc}
// Autoplay is the most common cross-app expectation; matches
// the BrowserCard default. Plugins / nodeintegration stay off.
// Autoplay matches BrowserCard default; plugins/nodeintegration stay off.
webpreferences="autoplayPolicy=no-user-gesture-required"
style={{
width: '100%',
@@ -369,14 +299,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
) : (
<iframe
ref={iframeRef}
// Key stable across reloads — only changes when switching MODES
// (URL vs srcdoc). Previously the key embedded reloadKey, which
// unmounted-and-remounted the iframe on every reload, producing
// a visible blank flash mid-burst. With a stable key, reloadKey
// still updates iframeSrc → React swaps the src attribute on
// the EXISTING iframe element → browser navigates in place,
// keeping the prior frame's pixels visible until the new doc
// paints. No flash.
// Key only changes on mode switch (URL vs srcdoc); reloadKey updates the src attribute in place to avoid blank-flash on reload.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={effectiveSrc}
onLoad={handleNavigationLoad}
@@ -408,7 +331,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
>
<Skeleton variant="card" width={140} height={14} delayMs={0} />
<Typography sx={{ fontSize: '0.78rem', color: '#888', letterSpacing: '0.01em' }}>
Restoring preview
Restoring preview...
</Typography>
</Box>
)}
+1 -3
View File
@@ -10,9 +10,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewCard from './ViewCard';
import { Skeleton } from '@/app/components/Loading';
import ViewRunDialog from './ViewRunDialog';
// ViewEditor pulls in CodeMirror (~600KB minified) and 1600+ lines of
// form scaffolding. Landing on /apps to browse the grid shouldn't pay
// that cost; lazy so the chunk only loads when the user opens an editor.
// Lazy: pulls CodeMirror (~600KB) + 1600 lines of form scaffolding, only needed when an editor opens.
const ViewEditor = lazy(() => import('./ViewEditor'));
const Views: React.FC = () => {
@@ -5,12 +5,7 @@ const CAPTURE_HEIGHT = 800;
const JPEG_QUALITY = 0.7;
const LOAD_TIMEOUT_MS = 4000;
// Workspace file keys are stored relative to the workspace root with no
// leading `./` or `/` — but agent-written HTML routinely references its
// siblings as `./style.css` or `/style.css`. Without normalizing here,
// `files[href]` lookup misses and the iframe renders unstyled, producing
// the broken thumbnails (text-only Markdown Editor, layoutless Calculator,
// etc.) you'd otherwise see on the Apps page.
// Normalize agent-written `./foo` and `/foo` references against workspace keys (which are root-relative).
function lookupFile(href: string, files: Record<string, string>): string | null {
const candidates = [href, href.replace(/^\.\//, ''), href.replace(/^\//, '')];
for (const k of candidates) {
@@ -19,10 +14,7 @@ function lookupFile(href: string, files: Record<string, string>): string | null
return null;
}
/**
* Inline local CSS/JS references so multi-file views render in a single srcdoc.
* External URLs (http://, https://, //) are left untouched.
*/
/** Inline local CSS/JS into a single srcdoc; external URLs are left as-is. */
function inlineResources(html: string, files: Record<string, string>): string {
let result = html;
@@ -76,13 +68,7 @@ window.OUTPUT_BACKEND_RESULT = null;
return `${injection}\n${frontendCode}`;
}
/**
* Renders a view in a hidden iframe, captures a JPEG screenshot,
* and returns a base64 data URL. Returns null on failure.
*
* Pass the full `files` map for multi-file views so local CSS/JS
* references are inlined into the srcdoc before rendering.
*/
/** Render a view in a hidden iframe and return a base64 JPEG thumbnail, or null on failure. */
export async function captureViewThumbnail(
frontendCode: string,
inputData: Record<string, any> = {},
@@ -295,7 +295,6 @@ export function useIframeElementSelector(explicitIframeRef?: RefObject<HTMLIFram
setupSelection();
}
} catch {
// iframe not ready yet
}
};
@@ -326,7 +325,6 @@ export function useIframeElementSelector(explicitIframeRef?: RefObject<HTMLIFram
return () => iframe.removeEventListener('load', onLoad);
}, [ctx?.selectMode, setupSelection, teardownSelection, getIframe]);
// Sync persistent highlights with selectedElements (handle removals & clears)
useEffect(() => {
if (!ctx) return;
const currentIds = new Set(ctx.selectedElements.map((e) => e.id));
+2 -13
View File
@@ -5,21 +5,10 @@ import ErrorBoundary from './app/components/ErrorBoundary';
import { ensureAuthToken } from './shared/config';
import { runStartupMigrations } from './shared/migrations';
// Run launch-time migrations BEFORE anything else touches localStorage
// or React state. The v1.0.31 migration force-clears auth + onboarding
// state so every user signs in fresh and walks the new tour. Must run
// before ensureAuthToken() reads from localStorage, otherwise the
// stale token survives.
// Must run before ensureAuthToken reads localStorage; v1.0.31 migration force-clears auth+onboarding so the stale token doesn't survive.
runStartupMigrations();
// Resolve the per-install auth token from Electron BEFORE first render
// so the very first fetch/WS carries the Authorization header. The
// token IPC is fast (synchronous file read in main process). We bound
// the wait at 3s so a missing Electron bridge (e.g. running the React
// app in a plain browser) doesn't hang forever — in that case
// `getAuthToken()` returns '' and backend calls will 401, which is
// the desired behavior (plain browsers can't be allowed to impersonate
// the user).
// 3s timeout so a missing Electron bridge (plain-browser dev) doesn't hang; 401 in that case is intentional.
async function bootstrap() {
try {
await Promise.race([
+12 -47
View File
@@ -137,9 +137,7 @@ async function handleType(wv: BrowserWebview, params: Record<string, any>): Prom
return result;
}
// Map common JS KeyboardEvent.key values to Electron's accelerator keyCodes.
// Electron's sendInputEvent expects: 'Up', 'Down', 'Left', 'Right', 'Enter',
// 'Escape', 'Tab', 'Backspace', 'Delete', 'Space', or single char letters.
// Electron sendInputEvent expects names like 'Up', 'Enter', 'Space', not 'ArrowUp'/' '/'Esc'.
const KEY_NAME_MAP: Record<string, string> = {
ArrowUp: 'Up',
ArrowDown: 'Down',
@@ -155,30 +153,15 @@ async function handlePressKey(wv: BrowserWebview, params: Record<string, any>):
const rawKey = (params.key as string) || '';
if (!rawKey) return { error: 'key parameter is required' };
const keyCode = KEY_NAME_MAP[rawKey] || rawKey;
// Focus the page first so the key event has a sensible target.
await wv.executeJavaScript('document.body && document.body.focus && document.body.focus(); true');
// Native OS-level key events — these have event.isTrusted === true so site
// keyboard handlers (Tinder, Slack, Notion, etc.) actually respect them.
// Native OS-level key events have isTrusted=true, so hostile sites' keyboard handlers respect them.
wv.sendInputEvent({ type: 'keyDown', keyCode });
wv.sendInputEvent({ type: 'char', keyCode });
wv.sendInputEvent({ type: 'keyUp', keyCode });
return { text: `Pressed ${rawKey}` };
}
// ---------------------------------------------------------------------------
// CDP accessibility-tree element indexing
// ---------------------------------------------------------------------------
// list_interactives uses Chrome DevTools Protocol's Accessibility.getFullAXTree
// to get the *computed* accessibility tree, not the raw DOM. This sees roles,
// names, and labels even on hostile sites (Tinder, Instagram) where the raw
// HTML is just unlabeled <div>s with click handlers — because Chromium computes
// accessible names for screen readers from icons, surrounding text, etc.
//
// Each interactive element is assigned a numeric index. The index → backendNodeId
// map is cached server-side per webContents and used by click_index. This is
// orders of magnitude more reliable than CSS-selector-based clicking on sites
// that don't expose semantic markup.
// CDP Accessibility.getFullAXTree sees computed roles/names even on hostile sites with unlabeled DOMs.
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'combobox', 'checkbox', 'menuitem',
'tab', 'switch', 'searchbox', 'slider', 'listbox', 'option',
@@ -211,7 +194,7 @@ async function sendCdp(wv: BrowserWebview, method: string, params?: Record<strin
const bridge = (window as any).openswarm?.sendCdpCommand as
| ((id: number, m: string, p?: any) => Promise<CdpResult>)
| undefined;
if (!bridge) throw new Error('CDP bridge not available restart the app');
if (!bridge) throw new Error('CDP bridge not available, restart the app');
const resp = await bridge(wcId, method, params);
if (!resp || !resp.ok) {
throw new Error(resp?.error || `CDP ${method} failed`);
@@ -237,7 +220,6 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
if (!INTERACTIVE_ROLES.has(role)) continue;
const name = extractAxValue(node.name);
if (!name && role !== 'textbox' && role !== 'searchbox' && role !== 'combobox') {
// Skip nameless elements unless they're inputs (which can be empty)
continue;
}
const backendNodeId = node.backendDOMNodeId;
@@ -246,8 +228,7 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
index++;
}
// Cache the index map in main-process storage so click_index can resolve it
// even across separate WebSocket commands.
// Cache in main-process so click_index can resolve across separate WS commands.
const indexMap: Record<number, number> = {};
for (const el of interactives) {
indexMap[el.index] = el.backendNodeId;
@@ -256,10 +237,9 @@ async function handleListInteractives(wv: BrowserWebview): Promise<Record<string
const cacheBridge = (window as any).openswarm?.cdpCacheSet;
if (cacheBridge) await cacheBridge(wv.getWebContentsId(), indexMap);
} catch {
// Cache is best-effort; click_index will fall back to re-listing.
// best-effort; click_index falls back to re-listing.
}
// Build the model-friendly text representation: [1]<button "Like">
const lines = interactives.map(
(el) => `[${el.index}]<${el.role} "${el.name}">`,
);
@@ -280,7 +260,6 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
return { error: 'index parameter is required and must be a positive integer' };
}
// Look up the cached index → backendNodeId mapping.
let backendNodeId: number | undefined;
try {
const cacheBridge = (window as any).openswarm?.cdpCacheGet;
@@ -300,9 +279,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// Cheap revalidation: resolve the backend node ID to a runtime object.
// If the page has mutated and the node is gone, this fails fast with a
// clear error message instead of clicking the wrong element.
// Revalidate: fails fast if the page mutated and the node is gone (vs. clicking the wrong element).
try {
await sendCdp(wv, 'DOM.resolveNode', { backendNodeId });
} catch (err: any) {
@@ -311,9 +288,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// Get the element's bounding box for clicking via Input.dispatchMouseEvent
// (more reliable than Element.click() on hostile sites — bypasses any
// synthetic-event filtering since these are real OS-level mouse events).
// Input.dispatchMouseEvent (OS-level) bypasses synthetic-event filtering on hostile sites.
let boxModel;
try {
boxModel = await sendCdp(wv, 'DOM.getBoxModel', { backendNodeId });
@@ -327,7 +302,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
if (!Array.isArray(content) || content.length < 8) {
return { error: `Index ${idx} has no valid bounding rect.` };
}
// content is [x1,y1, x2,y2, x3,y3, x4,y4] compute center
// content is [x1,y1, x2,y2, x3,y3, x4,y4]; compute center
const x = (content[0] + content[4]) / 2;
const y = (content[1] + content[5]) / 2;
@@ -355,16 +330,7 @@ async function handleClickIndex(wv: BrowserWebview, params: Record<string, any>)
};
}
// ---------------------------------------------------------------------------
// Batched actions
// ---------------------------------------------------------------------------
// handleBatch executes a list of sub-actions sequentially on the same webview,
// capturing the URL before/after each one and aborting the rest of the batch
// if the URL changes mid-batch (page navigated → indices and selectors are
// stale). This lets the model emit "[click_index 7, wait 500, type 'eric',
// press_key Enter]" in a single tool call instead of round-tripping for each
// action.
// Sequential sub-actions; aborts mid-batch if URL changes (indices/selectors go stale on navigation).
const MAX_BATCH_ACTIONS = 5;
type SubActionType =
@@ -403,7 +369,7 @@ async function handleBatch(wv: BrowserWebview, params: Record<string, any>): Pro
if (!subType || !(subType in BATCH_DISPATCH)) {
results.push({ index: i, type: subType, error: `Unknown sub-action type: ${subType}` });
// Continue with the rest — per-action failures don't abort the batch.
// per-action failures don't abort the batch
continue;
}
@@ -416,8 +382,7 @@ async function handleBatch(wv: BrowserWebview, params: Record<string, any>): Pro
}
results.push({ index: i, type: subType, ...subResult });
// If the URL changed, abort the rest — selectors and indices are stale
// and any subsequent actions would be operating on a half-loaded page.
// URL changed: selectors and indices are stale on the half-loaded page; abort.
const urlAfter = wv.getURL();
if (urlAfter !== urlBefore && i < actions.length - 1) {
aborted_at = i + 1;
+2 -13
View File
@@ -1,13 +1,4 @@
// Plain-JS shared ref (NOT React state) for "is the user currently
// interacting with the canvas" (pan/drag/wheel/zoom). Read on hot paths
// like AgentCard's ResizeObserver to suppress expensive work during the
// gesture. Setting/clearing the ref does NOT trigger any React re-renders.
//
// Why this pattern instead of Redux or context: ResizeObserver callbacks
// fire dozens of times per second during streaming. We want them to bail
// in O(1) without a subscription that itself has overhead. A module-level
// mutable holder + a one-shot "interaction ended" event meets both.
// Module-level ref (not React state) so ResizeObservers can bail O(1) without subscription overhead.
let _isPanning = false;
const listeners: Set<() => void> = new Set();
@@ -20,9 +11,7 @@ export function setCanvasInteractionActive(active: boolean) {
if (_isPanning === active) return;
const wasActive = _isPanning;
_isPanning = active;
// Fire the end-of-interaction notification so listeners can flush work
// that was suppressed during the gesture (re-measure heights, dispatch
// pending state updates, etc.).
// End-of-interaction: flush work suppressed during the gesture (re-measure, dispatch, etc.).
if (wasActive && !active) {
for (const fn of listeners) {
try { fn(); } catch (e) { console.warn('[canvas-interaction] listener threw', e); }
+6 -40
View File
@@ -3,18 +3,10 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
// Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the redirect
// URI registered on the Google OAuth client. The historical `.ai` value
// resolved to NXDOMAIN — fine while no frontend caller used it directly,
// but the v1.0.29 sign-in gate is the first frontend caller that
// constructs URLs from this constant, so the typo had to go.
// Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the Google OAuth redirect URI.
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.com';
// Per-install auth token. Fetched from Electron's main process via the
// preload contextBridge. We cache it after first resolution so every
// API/WS call is synchronous. On Electron hot-reload the token rotates;
// call `refreshAuthToken()` from a 4401 WS handler to pick up a new
// one without a full page reload.
// Per-install token from Electron preload; cached after first resolve. Call refreshAuthToken() on 4401.
let _authTokenCache: string = '';
let _authTokenPromise: Promise<string> | null = null;
@@ -35,33 +27,15 @@ export async function refreshAuthToken(): Promise<string> {
return _authTokenCache;
}
// Resolve-once helper: the first call kicks off the IPC request; any
// concurrent calls reuse the same promise. Frontend bootstrap awaits
// this before the first API call so the token is ready.
/** Resolve auth token once; concurrent callers share the same promise. */
export function ensureAuthToken(): Promise<string> {
if (_authTokenPromise) return _authTokenPromise;
_authTokenPromise = refreshAuthToken();
return _authTokenPromise;
}
// Install a global fetch interceptor so every fetch(API_BASE + ...)
// call site gets the Authorization header without touching each site.
// Covers the analytics, settings, agents, dashboards, etc. fetches.
// Only applies to requests that target our own API_BASE — pass-through
// for every other URL (3rd-party APIs, asset CDNs, etc.).
//
// Layered on top of the auth-injection: a tiny in-flight dedupe + 1s
// success cache for GETs. The onboarding flow + dashboard load fire the
// same `GET /api/agents/sessions/<id>` / `GET /api/skills/list` /
// `GET /api/skills/workspace/<id>` two-to-five times in quick
// succession when components mount near-simultaneously — without
// dedupe we paid a full roundtrip every time. With this in place the
// second-through-Nth call inside a 1 s window either piggybacks on
// the in-flight promise OR reads a freshly-cached Response. Cache is
// keyed by `METHOD URL`, scoped to GET only (mutations always fall
// through), and a Response.clone() per consumer keeps each caller's
// body stream independent. Non-2xx responses are NOT cached so a
// transient 5xx can't poison the next click.
// Global fetch interceptor: attaches bearer for our API + dedupes/caches GETs in a 1s window.
// Cache is keyed `METHOD URL`, GET-only (mutations pass through); non-2xx never cached.
const _inflightFetches = new Map<string, Promise<Response>>();
const _cachedFetches = new Map<string, { resp: Response; expiresAt: number }>();
const _GET_CACHE_TTL_MS = 1000;
@@ -74,11 +48,9 @@ function _installAuthFetchInterceptor() {
window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
// Only attach token for our own API. Everything else flows through.
const isOurApi = url.startsWith(API_BASE) || url.startsWith(`http://${host}:${port}/`);
if (!isOurApi) return originalFetch(input, init);
// Don't override an explicit Authorization the caller already set.
const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
const callerSetAuth = existingHeaders.has('Authorization') || existingHeaders.has('authorization');
@@ -96,9 +68,7 @@ function _installAuthFetchInterceptor() {
?? (input instanceof Request ? input.method : 'GET')
).toUpperCase();
// Only GET is safe to dedupe + cache. POST/PUT/PATCH/DELETE have
// side effects — collapsing two intentional calls (e.g. user
// double-clicked Send) would be wrong, so we always pass through.
// Only GET is safe to dedupe/cache; mutations could collapse intentional double-clicks.
if (method !== 'GET') {
return originalFetch(input, finalInit);
}
@@ -140,9 +110,5 @@ function _installAuthFetchInterceptor() {
};
}
// Call immediately on module load — config.ts is imported by the main
// entry point, so this runs before any component-level fetch.
_installAuthFetchInterceptor();
// Kick off token resolution in the background so it's warm by the
// time the first request goes out.
ensureAuthToken();
@@ -1,17 +1,6 @@
import React, { createContext, useContext } from 'react';
/**
* React context that signals whether the Dashboard is currently the active
* route (i.e. visible to the user) vs hidden in the background.
*
* Defaults to `true` so any standalone usage of dashboard children outside
* the DashboardHost wrapper just behaves normally.
*
* Heavy/expensive Dashboard children read this via `useDashboardActive()`
* and short-circuit their work when the dashboard is hidden that's how
* we keep CPU usage near-zero while the user is on /actions or /settings
* with the Dashboard mounted but invisible.
*/
/** True when Dashboard is the visible route; heavy children short-circuit when false. */
const DashboardActiveContext = createContext<boolean>(true);
export const DashboardActiveProvider = DashboardActiveContext.Provider;
+5 -25
View File
@@ -6,30 +6,17 @@ import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
// payload and dispatches activateSubscription so the backend validates and
// persists the bearer.
//
// Safe no-op in web/browser contexts where window.openswarm isn't defined.
/** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */
export function useDeepLink(): void {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
// Both listeners are optional — useDeepLink no-ops in browser/web context
// where window.openswarm is undefined.
if (!api) return;
const unsubscribe = api.onAuthUrl?.((rawUrl: string) => {
try {
// openswarm://auth?token=... (host = "auth", search carries fields).
// Two flavors land here, distinguished by the `signin` flag:
// - signin=true → free-tier sign-in (Google OAuth / magic link)
// - (default) → Stripe checkout subscription activation
// Note: the bearer-handoff page in lib/authMint.ts (cloud) POSTs
// directly to localhost so this deep-link path is currently a
// backstop for older flows. Both branches here remain wired up.
// openswarm://auth?token=... ; signin=true => free sign-in, else Stripe activation.
const url = new URL(rawUrl);
if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') {
console.warn('[deep-link] Unknown openswarm:// host:', url.host);
@@ -47,9 +34,7 @@ export function useDeepLink(): void {
const expires = url.searchParams.get('expires');
if (isSignin) {
// v1.0.29 only supports Google sign-in. signinMethodRaw is read
// for forward compatibility / analytics if other methods are
// added later.
// 1.0.29 only ships Google sign-in; read for forward compat.
void signinMethodRaw;
report('signin', 'deep_link_received', { method: 'google' });
@@ -82,8 +67,7 @@ export function useDeepLink(): void {
.unwrap()
.then((res) => {
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
// Refresh models so Pro-proxy Claude models appear in the picker immediately.
dispatch(fetchModels());
})
.catch((err) => {
@@ -97,15 +81,12 @@ export function useDeepLink(): void {
}
});
// OAuth claim deep-link listener. The Electron main process routes
// openswarm://oauth/{provider}/complete to its own IPC channel so we
// can claim tokens immediately rather than routing through Settings.
let unsubscribeOauth: (() => void) | undefined;
if (api?.onOauthClaim) {
unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => {
try {
// openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
const url = new URL(rawUrl);
// Expected: openswarm://oauth/{provider}/complete?session_id=...&tool_id=...
if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) {
console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl);
return;
@@ -131,7 +112,6 @@ export function useDeepLink(): void {
return;
}
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
console.error('[deep-link] OAuth claim threw:', e);
@@ -1,10 +1,4 @@
// Mounts a single global listener that records each user interaction
// timestamp into Redux. One installer per app — call from Main.tsx after
// the store is provided.
//
// Debounces at 1-second granularity so we don't spam Redux on every
// keystroke. Coarse enough for "idle dim after N minutes" UX; fine enough
// that the timestamp on session close is accurate to the second.
// Records user-interaction timestamps into Redux, 1s-debounced. Mount once from Main.tsx.
import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
@@ -4,23 +4,7 @@ import { useLocation } from 'react-router-dom';
const STORAGE_KEY = 'openswarm_last_dashboard_id';
const WINDOW_KEY = '__openswarm_last_dashboard_id';
/**
* Tracks the last visited dashboard id in a "sticky" way: once a dashboard
* has been visited, the id stays set even when the user navigates to other
* routes. This is the foundation for keeping the Dashboard component mounted
* across non-dashboard route navigation (hide-don't-unmount pattern).
*
* The Dashboard component reads its dashboardId from this hook (via a prop
* passed by AppShell) instead of from `useParams()`, so the id never goes
* undefined when the URL changes to /actions etc. This prevents the
* dashboardId useEffect from re-firing on every incidental route change,
* which would cause `resetLayout` + `fetchLayout` and visibly reload the
* browser cards.
*
* Returns a tuple of `[lastDashboardId, setLastDashboardId]`. The setter
* is exposed so explicit dashboard close/delete handlers can clear it
* (which causes the Dashboard to fully unmount and tear down its webviews).
*/
/** Sticky last-visited dashboard id so Dashboard stays mounted across non-dashboard nav. */
export function useLastDashboardId(): [string | null, (id: string | null) => void] {
const location = useLocation();
const [lastId, setLastIdState] = useState<string | null>(() => {
@@ -31,8 +15,7 @@ export function useLastDashboardId(): [string | null, (id: string | null) => voi
}
});
// Watch the URL — when it matches /dashboard/:id, update the sticky id.
// Critically: do NOT clear the sticky id when the URL stops matching.
// Watch URL; update sticky id on /dashboard/:id. Do NOT clear when URL stops matching.
useEffect(() => {
const match = location.pathname.match(/^\/dashboard\/([^/]+)/);
if (match && match[1] && match[1] !== lastId) {
+2 -21
View File
@@ -5,7 +5,6 @@ const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(callback: () => void): () => void {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
// Modern + legacy event names both supported.
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
@@ -19,30 +18,12 @@ function getServerSnapshot(): boolean {
return false;
}
/**
* True when the OS-level "Reduce motion" preference is on.
* Mac: System Settings Accessibility Display Reduce Motion.
* Windows: Settings Ease of Access Display Show animations.
*
* Reactive flips immediately if the user toggles the OS setting
* mid-session (rare but supported).
*/
/** True when the OS "Reduce motion" preference is on; reactive to OS toggles. */
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/**
* Convenience: returns 0 when reduced-motion is on, otherwise the supplied
* duration. Use inline at animation sites:
*
* const dur = useMotionDuration(DURATION_MS.quick);
* <Fade timeout={dur}>...</Fade>
*
* For animations that convey causality (modal open, drawer slide), prefer a
* tiny non-zero floor so the user still perceives the transition:
*
* const dur = useMotionDuration(DURATION_MS.standard, { floor: 40 });
*/
/** Returns 0 (or `opts.floor`) when reduced-motion is on, else `ms`. */
export function useMotionDuration(ms: number, opts: { floor?: number } = {}): number {
const reduced = useReducedMotion();
if (!reduced) return ms;
+2 -13
View File
@@ -1,12 +1,4 @@
// Route-change tracker.
//
// Reports a `nav.route_changed` event on every React Router location
// change so the cloud can aggregate visits per route. Reuses the
// existing report() surface — no new outbound paths added. The desktop
// just sends the path; the cloud counts.
//
// Mount inside a Router (must be a child of HashRouter / BrowserRouter)
// so useLocation() resolves.
// Reports nav.route_changed on each React Router location change. Mount inside a Router.
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
@@ -14,8 +6,7 @@ import { report } from '@/shared/serviceClient';
export function useRouteTracker(): void {
const location = useLocation();
// Skip the very first render the App opens at "/" and we don't want
// to report a phantom navigation that didn't happen.
// Skip first render so the App's "/" open doesn't fire a phantom nav.
const skippedFirst = useRef(false);
const lastPath = useRef<string>('');
@@ -28,8 +19,6 @@ export function useRouteTracker(): void {
}
if (path === lastPath.current) return;
lastPath.current = path;
// The path is a route name (e.g. /dashboard, /settings) — never the
// full URL. No query strings, no hash fragments beyond the route id.
report('nav', 'route_changed', { path });
}, [location.hash, location.pathname]);
}
@@ -1,19 +1,4 @@
// Single source of truth for "what URL should the preview webview point at?"
//
// New-mode webapp_template workspaces have no root index.html — the live
// preview lives behind the workspace's own Vite dev server, whose port is
// announced by the backend's runtime:status WS frame. Old-mode flat
// workspaces still serve files through the legacy /api/outputs/.../serve/
// endpoints. This hook hides the difference: it attaches to the runtime
// (ref-counted server-side, so multiple subscribers share one process),
// listens for status, and exposes the live frontend_url + new-mode flag.
// Consumers compute their final URL with `pickPreviewUrl()` below.
//
// Used by both ViewEditor (editor tab) and DashboardViewCard (dashboard
// canvas). Earlier each component had its own copy of this effect and
// only the editor had the new-mode logic — that's why dashboard cards
// for webapp_template apps were rendering the literal "File not found
// in output" JSON. One hook now, both consumers stay in sync.
// Hides legacy /serve/ vs new-mode Vite-runtime split for preview URLs; ref-counted spawn.
import { useEffect, useRef, useState } from 'react';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -27,25 +12,14 @@ export interface RuntimeLogLine {
export interface RuntimePreviewState {
frontendUrl: string | null;
isNewMode: boolean;
// True for the first ~400ms after subscribing — gives the runtime WS a
// chance to send its initial runtime:status frame before consumers
// decide to render a "Starting preview…" placeholder. Without this
// gate, dashboard cards flashed the placeholder every remount even
// when Vite was already up, because frontendUrl resets to null on
// mount and arrives one tick later.
// True until the runtime:status frame lands; prevents placeholder flash on remount when Vite is up.
isHydrating: boolean;
}
export interface RuntimePreviewOptions {
// Workspace to attach to. null/undefined → no-op (no spawn, no WS).
workspaceId: string | null | undefined;
// Gate the spawn. Lets callers defer paying the runtime cost until
// the user actually wants the preview (ViewEditor only spawns once
// the user clicks Preview or Terminal). Dashboard cards default to
// true since the preview pane is always visible.
/** Gate the spawn so callers can defer paying runtime cost until preview is wanted. */
enabled?: boolean;
// Optional sink for log lines. Editor's terminal panel uses this;
// dashboard cards don't need it and can omit.
onLog?: (line: RuntimeLogLine) => void;
}
@@ -54,9 +28,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
const [isNewMode, setIsNewMode] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
// Pin the latest onLog so we don't tear down + respawn the runtime
// every time the callback identity changes. The effect only depends
// on workspaceId + enabled.
// Pin latest onLog so callback identity changes don't tear down/respawn the runtime.
const onLogRef = useRef(onLog);
onLogRef.current = onLog;
@@ -70,12 +42,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
setFrontendUrl(null);
setIsNewMode(false);
setIsHydrating(true);
// Drop the hydrating flag after the WS has had time to deliver its
// initial runtime:status frame. With the backend's 80ms poll
// interval, status almost always arrives in 20-100ms; 150ms is
// generous enough that warm starts never flash the booting
// placeholder, while not making genuinely-cold runtimes wait an
// extra half second before showing "Starting preview…".
// 150ms: warm starts deliver status in 20-100ms; long enough to skip placeholder flash, short enough to not stall cold starts.
const hydrationTimer = setTimeout(() => {
if (!cancelled) setIsHydrating(false);
}, 150);
@@ -91,7 +58,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
headers,
});
} catch (_) {
// Spawn errors surface via the log WS. Don't double-report.
// Spawn errors surface via the log WS; don't double-report.
}
if (cancelled) return;
try {
@@ -105,7 +72,6 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
const fu = msg.data?.frontend_url ?? null;
setFrontendUrl(fu || null);
setIsNewMode(!!msg.data?.is_new_mode);
// Status arrived; hand off to the real ready/booting gate.
setIsHydrating(false);
} else if (msg.event === 'runtime:log') {
const stream = msg.data?.stream || 'stdout';
@@ -118,8 +84,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
}
};
} catch (_) {
// WS construction failed (CSP, bad URL, etc). Caller stays in
// its "no preview yet" state — same shape as a slow Vite cold start.
// WS construction failed; caller stays in "no preview yet" state.
}
})();
@@ -130,9 +95,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
setFrontendUrl(null);
setIsNewMode(false);
setIsHydrating(true);
// detach is ref-counted on the backend — only the last subscriber
// actually tears down the runtime, the rest are no-ops. We fire
// and forget; errors here would be transient and don't affect UX.
// detach is ref-counted on the backend; fire-and-forget.
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, {
method: 'POST',
headers,
@@ -145,40 +108,25 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
export interface PickPreviewUrlOptions {
workspaceId: string | null | undefined;
// Legacy fallback URL for old-mode flat workspaces. Pass the URL the
// component used BEFORE the new-mode split (ViewEditor uses
// `${SERVE_BASE}/workspace/${ws}/serve/index.html`, dashboard cards
// use `${SERVE_BASE}/${output_id}/serve/index.html`). When the runtime
// says we're in new-mode AND Vite is up, we override with frontendUrl.
/** Pre-new-mode URL the component used (serve/index.html); overridden by frontendUrl when ready. */
legacyUrl: string | undefined;
frontendUrl: string | null;
isNewMode: boolean;
}
export interface PickPreviewUrlResult {
// Final URL the preview should load. `undefined` means "show placeholder
// instead" — happens when the workspace is new-mode but Vite hasn't
// bound yet (cold start, npm install in progress, runtime crashed).
/** undefined => render placeholder (new-mode and Vite not bound yet). */
url: string | undefined;
// True iff we're in new-mode and frontendUrl hasn't arrived. UI uses
// this to render a "Starting preview…" affordance instead of letting
// the webview attempt the legacy URL (which 404s in new-mode).
isBooting: boolean;
}
export function pickPreviewUrl(opts: PickPreviewUrlOptions): PickPreviewUrlResult {
const { legacyUrl, frontendUrl, isNewMode, workspaceId } = opts;
if (!workspaceId) {
// No workspace id at all (output never seeded one). Use whatever
// legacy URL the caller computed — typical for old flat outputs
// that were created before workspace_id became standard.
return { url: legacyUrl, isBooting: false };
}
if (isNewMode && !frontendUrl) {
return { url: undefined, isBooting: true };
}
// Prefer frontendUrl when present (works for both new-mode that's up
// AND any future caller that gives us a Vite URL). Fall back to the
// legacy serve URL for old-mode workspaces.
return { url: frontendUrl ?? legacyUrl, isBooting: false };
}

Some files were not shown because too many files have changed in this diff Show More