mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 12:42:22 +02:00
defluff (frontend + backend): strip em-dashes + shorten docstrings + drop dead UI files (cosmetic only, no schedule code)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 +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."""
|
||||
|
||||
@@ -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-")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }))}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 1–100 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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user