Merge branch 'eric/dev' into eric/redesign

# Conflicts:
#	backend/apps/agents/manager/run/RunOptions.py
#	electron/main.js
#	frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
#	frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx
#	frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx
#	frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts
This commit is contained in:
ciregenz
2026-07-19 21:40:18 -07:00
99 changed files with 2835 additions and 689 deletions
+17 -6
View File
@@ -64,20 +64,31 @@ const _http = require('http');
const backendPort = process.env.OPENSWARM_PORT || '8324';
const path = '/api/subscriptions/callback' + url.slice('/callback'.length);
let done = false;
const finish = () => {
// Relay the backend's real outcome page: the old static close-page rendered success even when the exchange failed, so a broken claude connect looked like it worked and left nothing to debug from user reports.
const finish = (body) => {
if (done) return;
done = true;
try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(closePage); } catch (_) {}
try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(body || closePage); } catch (_) {}
};
try {
const proxyReq = http.request(
{ host: '127.0.0.1', port: backendPort, path: path, method: 'GET' },
(proxyRes) => { proxyRes.resume(); proxyRes.on('end', finish); }
(proxyRes) => {
const chunks = [];
proxyRes.on('data', (c) => { if (chunks.length < 64) chunks.push(c); });
proxyRes.on('end', () => finish(Buffer.concat(chunks).toString('utf8') || null));
proxyRes.on('error', () => finish(null));
}
);
proxyReq.on('error', finish);
proxyReq.setTimeout(5000, () => { try { proxyReq.destroy(); } catch (_) {} finish(); });
proxyReq.on('error', () => finish(
'<!doctype html><meta charset="utf-8"><body style="font-family:-apple-system,system-ui;' +
'text-align:center;color:#c66;padding-top:80px;background:#1a1a1a">' +
'Connection failed: OpenSwarm is not reachable on this machine (port ' + backendPort + '). ' +
'Open the OpenSwarm app and try connecting again.</body>'
));
proxyReq.setTimeout(15000, () => { try { proxyReq.destroy(); } catch (_) {} finish(null); });
proxyReq.end();
} catch (_) { finish(); }
} catch (_) { finish(null); }
return true;
}
} catch (_) {}
+2 -1
View File
@@ -20,6 +20,7 @@ from backend.apps.agents.manager.session.session_store import (
from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState
from backend.apps.agents.manager.streaming.PartialReply import PartialReply
from backend.apps.agents.manager.session.SessionLifecycle import SessionLifecycle
from backend.apps.agents.manager.SpawnAgentRun import SpawnAgentRun
from backend.apps.agents.manager.session.SessionPersistence import SessionPersistence
from backend.apps.agents.manager.Messaging import Messaging
from backend.apps.agents.manager.SessionControl import SessionControl
@@ -38,7 +39,7 @@ os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
MAX_CONCURRENT_TURNS = int(os.environ.get("OSW_MAX_CONCURRENT_TURNS", "8") or "0")
class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, MockAgent, TurnRunner, RunOptions, RunSupport):
class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, SpawnAgentRun, MockAgent, TurnRunner, RunOptions, RunSupport):
@typechecked
def __init__(self):
self.sessions: Dict[str, AgentSession] = {}
+51 -15
View File
@@ -1,15 +1,17 @@
from backend.config.Apps import SubApp
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.core.models import AgentConfig, ApprovalResponse
from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input
from contextlib import asynccontextmanager
from fastapi import WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
import asyncio
import json
import logging
import time
from contextlib import asynccontextmanager
from typing import Any, Dict
from fastapi import HTTPException
from typeguard import typechecked
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentConfig, AgentSession, ApprovalResponse
from backend.apps.agents.core.seq_log import seq_log
from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input
from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
@@ -39,17 +41,46 @@ async def agents_lifespan():
agents = SubApp("agents", agents_lifespan)
@typechecked
def p_session_list_item(session: AgentSession) -> Dict[str, Any]:
"""Serialize dashboard metadata without retaining the full chat history."""
data = session.model_dump(mode="json", exclude={"messages"})
messages = session.messages
last_content = messages[-1].content if messages else ""
first_user_content = next(
(message.content for message in messages if message.role == "user"),
"",
)
data.update(
messages=[],
last_message_preview=last_content[:120] if isinstance(last_content, str) else "",
first_user_message=(
first_user_content[:200] if isinstance(first_user_content, str) else ""
),
message_count=len(messages),
)
return data
@agents.router.get("/sessions")
async def list_sessions(dashboard_id: str = ""):
sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None)
return {"sessions": [s.model_dump(mode="json") for s in sessions]}
return {"sessions": [p_session_list_item(s) for s in sessions]}
@agents.router.get("/activity")
async def agent_activity():
"""How many agent tasks are live right now. Drives the desktop's idle-update gate so a
silent update-on-idle never lands on top of a running agent."""
"""How many agent tasks are live right now, plus seconds until the next scheduled
workflow fires. Drives the desktop's idle-update gate so a silent update-on-idle
never lands on top of a running agent or right before a scheduled run."""
active = sum(1 for t in agent_manager.tasks.values() if not t.done())
return {"active": active}
try:
# Local import: workflows pulls in agent machinery, a module-level import here would cycle.
from backend.apps.workflows.scheduler import seconds_to_next_fire
next_run_in_s = seconds_to_next_fire()
except Exception:
# Fail open (None = no block): a broken lookahead must never wedge updates forever; the agents gate still protects running work.
next_run_in_s = None
return {"active": active, "next_run_in_s": next_run_in_s}
@agents.router.get("/sessions/{session_id}")
async def get_session(session_id: str):
@@ -70,7 +101,11 @@ async def get_session(session_id: str):
session = await agent_manager.resume_session(session_id)
except ValueError:
raise HTTPException(status_code=404, detail="Session not found")
return session.model_dump(mode="json")
# Seq read before the dump (no await between = atomic): the client seeds its WS resume cursor from this, so a REST hydrate isn't followed by a full from-zero replay of everything it just received.
event_seq = seq_log.current_seq(session_id)
payload = session.model_dump(mode="json")
payload["event_seq"] = event_seq
return payload
@agents.router.post("/launch")
async def launch_agent(config: AgentConfig):
@@ -251,10 +286,11 @@ async def delete_session(session_id: str):
return {"ok": True}
@agents.router.get("/history")
async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str = ""):
async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str = "", closed_only: int = 0):
return agent_manager.get_history(
q=q, limit=limit, offset=offset,
dashboard_id=dashboard_id or None,
closed_only=bool(closed_only),
)
@agents.router.get("/sessions/{session_id}/browser-agents")
+45 -1
View File
@@ -2011,6 +2011,11 @@ async def run_browser_agent(
f"[browser-agent {session_id}] browser card {browser_id} is unusable "
f"({card_gone_streak} consecutive gone/hung results); aborting fast"
)
if os.environ.get("OSW_DEADCARD_EVICT", "1") != "0":
DEAD_CARDS.add(browser_id)
logger.info(f"[browser-agent] {browser_id} marked dead; same-host reuse will skip it")
# Tear the wedged webview DOWN now, before recovery spawns a fresh card. Two heavy pages (the dead one + the recovery one) starve the renderer's event loop = the recovery-card wedge; unmounting the dead one frees its renderer so the recovery card is the only heavy neighbor.
await evict_dead_card(dashboard_id, browser_id)
break
if cancel_event.is_set():
@@ -2217,6 +2222,8 @@ async def run_browser_agent(
# Cards a sub-agent is actively driving in this process. Reuse must never hand two agents one webview (their commands would interleave into chaos).
ACTIVE_AGENT_CARDS: set[str] = set()
# Cards a browser agent gave up on (gone/hung). Same-host reuse skips them so a retry never grabs a wedged card; the evict tears the agent-spawned ones down.
DEAD_CARDS: set[str] = set()
# find+claim+create must be one critical section or two parallel dispatches race to claim the same idle card (or both miss and double-create).
p_card_pick_lock = asyncio.Lock()
@@ -2239,7 +2246,7 @@ def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | Non
own, orphan = "", ""
for bid, card in cards.items():
spawned = getattr(card, "spawned_by", None)
if not spawned or bid in ACTIVE_AGENT_CARDS:
if not spawned or bid in ACTIVE_AGENT_CARDS or bid in DEAD_CARDS:
continue
if browser_skills.host_of(getattr(card, "url", "") or "") != want:
continue
@@ -2252,6 +2259,43 @@ def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | Non
return own or orphan
# The renderer needs a beat to unmount the <webview> and let Electron free its renderer process. Recovery spawns its fresh card the instant this returns, so we hold here until the teardown has almost certainly landed, else the new card mounts next to a still-freeing dead one and eats the same 15s starvation cap. Failure-path only, so its cost is invisible next to the cap it prevents.
P_EVICT_SETTLE_S = 1.5
async def evict_dead_card(dashboard_id: str | None, browser_id: str) -> None:
"""Free a wedged card's webview so the recovery card isn't its heavy neighbor: tell the
renderer to unmount it (frees the renderer process), drop it from the persisted layout, and
WAIT for teardown before the caller spawns the recovery card. Fail-open, never raises into the
abort path. ONLY agent-spawned cards are evicted: a user's own card must never be deleted out
from under them, wedged or not; for those the DEAD_CARDS reuse-skip is the whole remedy."""
ACTIVE_AGENT_CARDS.discard(browser_id)
try:
from backend.apps.dashboards.dashboards import load as p_dash_load
p_card = p_dash_load(dashboard_id).layout.browser_cards.get(browser_id) if dashboard_id else None
if p_card is None or not getattr(p_card, "spawned_by", None):
logger.info(f"[browser-agent] {browser_id} is not an agent-spawned card; skipping evict (reuse-skip only)")
return
except Exception:
return
try:
await ws_manager.broadcast_global("dashboard:browser_card_evict", {
"dashboard_id": dashboard_id or "", "browser_id": browser_id})
except Exception:
pass
if dashboard_id:
try:
from backend.apps.dashboards.dashboards import load, save
dash = load(dashboard_id)
if browser_id in dash.layout.browser_cards:
del dash.layout.browser_cards[browser_id]
dash.updated_at = datetime.now()
save(dash)
except Exception:
pass
await asyncio.sleep(P_EVICT_SETTLE_S)
async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str:
"""Create a new browser card on the dashboard and return its browser_id."""
from backend.apps.dashboards.dashboards import load, save
+29 -1
View File
@@ -23,6 +23,29 @@ BROWSER_CMD_REBROADCAST_S = 3.0
P_WS_RECONNECT_WAIT_S = 8.0
def slim_status_data(event: str, data: dict) -> dict:
"""agent:status frames carry session METADATA, never the transcript: every message already
reaches clients as its own agent:message event (and the stream), so re-shipping full history
per status flip was pure duplication, and replayed stale copies rolled clients backwards.
Preview fields mirror p_session_list_item so collapsed-card previews keep working."""
if event != "agent:status":
return data
sess = data.get("session")
if not isinstance(sess, dict) or not sess.get("messages"):
return data
messages = sess["messages"]
last = messages[-1].get("content", "")
first_user = next((m.get("content") for m in messages if m.get("role") == "user"), "")
slim = dict(sess)
slim["messages"] = []
slim["last_message_preview"] = last[:120] if isinstance(last, str) else ""
slim["first_user_message"] = first_user[:200] if isinstance(first_user, str) else ""
slim["message_count"] = len(messages)
out = dict(data)
out["session"] = slim
return out
async def await_reconnect(has_conn) -> bool:
"""Poll up to P_WS_RECONNECT_WAIT_S for a dashboard socket to (re)appear.
`has_conn` is a 0-arg callable returning truthy when connected."""
@@ -94,6 +117,7 @@ class ConnectionManager:
async def send_to_session(self, session_id: str, event: str, data: dict):
"""Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk."""
data = slim_status_data(event, data)
async with seq_log.stamp(session_id, event, data) as (seq, payload_str):
for ws in list(self.connections.get(session_id, [])):
try:
@@ -163,6 +187,10 @@ class ConnectionManager:
"to_seq": newest,
}
# Live log and the client is at (or past) the top: caught up, nothing to send. Without this, every cursor-seeded reconnect got the persisted terminal frame re-sent as "replay".
if newest is not None and last_seq >= newest and newest > 0:
return {"ok": True, "replayed": 0, "current_seq": newest}
terminal = seq_log.load_terminal(session_id)
if terminal is not None:
try:
@@ -225,7 +253,7 @@ class ConnectionManager:
async def broadcast_global(self, event: str, data: dict):
"""Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch)."""
payload = json.dumps({"event": event, "data": data})
payload = json.dumps({"event": event, "data": slim_status_data(event, data)})
dead: list[WebSocket] = []
for ws in list(self.global_connections):
try:
@@ -0,0 +1,99 @@
"""spawn_agent: back the SpawnAgent MCP tool with a FRESH sub-agent session (no history
copy; the prompt must be self-contained). Replaces the CLI's built-in Agent tool, which is
blocked in RunOptions: its subagent types resolve to models router setups can't serve, and
its schema drags description/subagent_type/model/isolation along. Mixin, same MRO pattern
as AgentLaunch."""
import asyncio
import logging
from datetime import datetime
from typing import Dict, Optional
from uuid import uuid4
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
from backend.apps.agents.manager.session.session_store import load_session_data
logger = logging.getLogger(__name__)
def last_assistant_text(session: AgentSession) -> Optional[str]:
for msg in reversed(session.messages):
if msg.role == "assistant":
content = msg.content
if isinstance(content, str):
return content
if isinstance(content, list):
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
return "\n".join(texts)
return str(content)
return None
class SpawnAgentRun(AgentManagerProtocol):
@typechecked
async def spawn_agent(
self,
prompt: str,
parent_session_id: str,
dashboard_id: Optional[str] = None,
run_in_background: bool = False,
) -> Dict:
parent = self.sessions.get(parent_session_id)
if not parent:
data = load_session_data(parent_session_id)
if data is None:
raise ValueError(f"Parent session {parent_session_id} not found")
parent = AgentSession(**data)
title = (prompt.strip().splitlines() or [""])[0][:60] or "Sub-agent"
child = AgentSession(
id=uuid4().hex,
name=title,
status="running",
model=parent.model,
mode="sub-agent",
system_prompt=parent.system_prompt,
allowed_tools=list(parent.allowed_tools),
max_turns=parent.max_turns or 25,
cwd=parent.cwd,
created_at=datetime.now(),
dashboard_id=dashboard_id or parent.dashboard_id,
parent_session_id=parent_session_id,
)
apply_context_window(child)
self.sessions[child.id] = child
await ws_manager.broadcast_global("agent:status", {
"session_id": child.id,
"status": child.status,
"session": child.model_dump(mode="json"),
})
user_msg = Message(
role="user",
content=prompt,
branch_id=child.active_branch_id,
)
child.messages.append(user_msg)
await ws_manager.send_to_session(child.id, "agent:message", {
"session_id": child.id,
"message": user_msg.model_dump(mode="json"),
})
if run_in_background:
# Fire-and-forget; the child's card carries its progress and result. Keep a handle in self.tasks so stop/shutdown machinery sees it.
task = asyncio.create_task(self.run_agent_loop(child.id, prompt))
self.tasks[child.id] = task
return {"session_id": child.id, "background": True}
await self.run_agent_loop(child.id, prompt)
return {
"session_id": child.id,
"response": last_assistant_text(child) or "No response from sub-agent.",
"cost_usd": child.cost_usd,
}
@@ -1,10 +1,9 @@
"""Configure the SDK environment for the run's provider route: set ANTHROPIC/OPENAI/GOOGLE
auth env vars (direct key, OpenSwarm Pro proxy, OpenRouter, or 9Router) and pin subagent models,
ensuring 9Router is up where the route needs it. sub_conns is the active-connection list for
subagent-model fallback (empty today)."""
ensuring 9Router is up where the route needs it."""
import os
from typing import Dict, List, Optional
from typing import Dict, Optional
from typeguard import typechecked
@@ -50,7 +49,6 @@ async def configure_provider_env(
resolved_model: object,
api_type: Optional[str],
global_settings: AppSettings,
sub_conns: List,
) -> None:
from backend.apps.nine_router import is_running as nine_router_running
from backend.apps.agents.providers.registry import NINEROUTER_MODEL_PREFIXES as NINEROUTER_MODEL_PREFIXES
@@ -198,7 +196,9 @@ async def configure_provider_env(
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
# Pin subagents to whichever lane the user has, else CLI's default Haiku 4.5 hits 9Router with no Claude route and 401s. NOTE: callers pass sub_conns=[] today so this is inert (latent regression from the run/ split; pyright caught the dangling _conns ref).
# Pin subagents to whichever lane the user has, else the CLI's default Haiku 4.5 hits 9Router with no Claude route and every sub-agent 401s while the parent turn works. Fetched live here (fail-open []) so no caller can starve the pin with a stale list again, the run/ split did exactly that and silently killed sub-agents on router routes.
from backend.apps.nine_router import get_providers as p_get_providers
sub_conns = await p_get_providers()
active = {c.get("provider") for c in sub_conns
if isinstance(c, dict) and c.get("isActive")}
sub_model = None
@@ -64,6 +64,14 @@ def build_effective_tool_lists(
effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}")
continue
if name == "openswarm-spawn-agent":
policy = builtin_perms.get("Agent", "always_allow")
if policy == "always_allow":
effective_allowed.append("mcp__openswarm-spawn-agent__SpawnAgent")
elif policy == "deny":
effective_disallowed.append("mcp__openswarm-spawn-agent__SpawnAgent")
continue
if name == "openswarm-skill":
policy = builtin_perms.get("Skill", "always_allow")
if policy == "always_allow":
@@ -1,5 +1,6 @@
import logging
from typing import List, Optional, Set
import os
from typing import Dict, List, Optional, Set, Union
from typeguard import typechecked
@@ -12,19 +13,33 @@ from backend.apps.tools_lib.tools_lib import (
logger = logging.getLogger(__name__)
# Cron* live only in the force-deny list (path_gate): our Schedule MCP replaces the CLI scheduler, so allowing them here just churned the allow/deny lists. InvokeAgent's real tool is the mcp__openswarm-invoke-agent__ ref; the bare name was a no-op.
FULL_TOOLS = [
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
"WebSearch", "WebFetch", "NotebookEdit", "TodoWrite",
"EnterPlanMode", "ExitPlanMode", "EnterWorktree",
"TaskOutput", "TaskStop",
"CronCreate", "CronList", "CronDelete",
"InvokeAgent",
"Agent",
# ToolSearch is the loader the CLI uses to expose deferred tool schemas on demand. Must be in the allowedTools whitelist or the model can't call it, which means none of the deferred extended tools become reachable even when the CLI advertises them in the system prompt.
"ToolSearch",
]
@typechecked
def resolve_builtin_tools_option() -> Union[List[str], Dict[str, str]]:
"""The SDK `tools` option (CLI `--tools`), the base set of built-in tools.
Default: the full claude_code preset, which ships every preset built-in's schema. With
OSW_TOOL_MANIFEST=1, an explicit FULL_TOOLS list instead, ONLY the built-ins OpenSwarm exposes,
which prunes the ~9 preset extras nothing here references (Cron*/Monitor/Task*/PushNotification/
RemoteTrigger/etc) for ~940 schema tokens/turn, cache-stable. MCP tools ride mcp_servers, so
SpawnAgent/browser/schedule/skill/web/user MCPs are untouched; ToolSearch stays in FULL_TOOLS so
deferred loading survives (live-proven: the model still ToolSearch-loads + calls MCP tools under
the manifest). Flag-gated pending a real-app soak before default-on."""
if os.environ.get("OSW_TOOL_MANIFEST") == "1":
return list(FULL_TOOLS)
return {"type": "preset", "preset": "claude_code"}
@typechecked
def get_denied_tool_names(tool: ToolDefinition) -> Set[str]:
"""Return the set of MCP sub-tool names whose permission is 'deny'."""
@@ -78,6 +78,23 @@ def register_builtin_mcp_servers(
"type": "stdio",
}
# SpawnAgent replaces the CLI's built-in Agent tool (blocked in RunOptions); gated by the same "Agent" permission so the Tools-page toggle keeps working.
if builtin_perms.get("Agent", "always_allow") != "deny":
spawn_agent_server_path = os.path.join(
agents_dir, "spawn_agent_mcp_server.py"
)
mcp_servers["openswarm-spawn-agent"] = {
"command": sys.executable,
"args": [spawn_agent_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": get_auth_token(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
},
"type": "stdio",
}
# Always-on meta-MCP server. Exposes MCPList / MCPSearch / MCPActivate so the model can discover and activate user MCPs at runtime. The activation gate (active_mcps filter in build_mcp_servers above) ensures the model cannot reach any other MCP server's tools without going through this layer first.
mcp_meta_server_path = os.path.join(
agents_dir, "mcp_meta_server.py"
+7 -14
View File
@@ -25,11 +25,11 @@ from backend.apps.agents.manager.configure_provider_env import configure_provide
from backend.apps.agents.manager.session.workspace_git import ensure_cwd_git_repo
from backend.apps.agents.manager.session.history_compaction import build_history_prefix, get_branch_messages
from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt
from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names
from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names, resolve_builtin_tools_option
from backend.apps.agents.manager.prompt.prompt_context import resolve_mode
from backend.apps.agents.manager.run.run_options_helpers import (
pre_send_context_guard, set_framework_overhead, register_web_mcp_server,
append_web_tools_hint, inject_thinking_options,
append_web_tools_hint, inject_thinking_options, merge_hard_blocked_tools,
)
logger = logging.getLogger(__name__)
@@ -188,17 +188,14 @@ class RunOptions(AgentManagerProtocol):
}
# cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key.
await configure_provider_env(
options_kwargs, session, resolved_model, api_type, global_settings, []
options_kwargs, session, resolved_model, api_type, global_settings
)
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
mcp_json_len = len(json.dumps({"mcpServers": mcp_servers}))
logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}")
# claude_code preset for BOTH system_prompt and tools so the CLI's deferred-tools scaffolding survives. Raw string would replace it.
options_kwargs["tools"] = {
"type": "preset",
"preset": "claude_code",
}
# Built-in tool surface (preset vs pruned FULL_TOOLS manifest); see resolve_builtin_tools_option. system_prompt keeps the preset regardless.
options_kwargs["tools"] = resolve_builtin_tools_option()
# exclude_dynamic_sections=True moves cwd/git/OS grounding out of the cached prefix and into the first user message, unlocks Anthropic prompt cache (~80% input-token cut, 13-31% faster TTFT). Trade-off: grounding freezes at turn 1.
if composed_prompt:
options_kwargs["system_prompt"] = {
@@ -227,12 +224,8 @@ class RunOptions(AgentManagerProtocol):
options_kwargs["extra_args"] = p_ea
# The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call.
# EXTEND, never reassign: a plain assignment here silently discarded every effective_disallowed
# entry (read-only sessions could still run Bash). Same fix as eric/dev ce3e67d6.
options_kwargs["disallowed_tools"] = [
*(options_kwargs.get("disallowed_tools") or []),
"mcp__claude_ai_*",
]
# merge EXTENDS effective_disallowed: the old plain assignment silently discarded the computed denies (Cron*/Skill/web-swap/per-tool MCP) and left the runtime gate as the only wall.
options_kwargs["disallowed_tools"] = merge_hard_blocked_tools(effective_disallowed)
if session.cwd:
# Pre-existing sessions may have workspaces that predate the git-init block in launch_agent, leaving them without a valid HEAD. Ensure it here so subagent worktree-add always works.
@@ -12,6 +12,15 @@ from backend.apps.agents.manager.session.history_compaction import estimate_post
logger = logging.getLogger(__name__)
# Always SDK-blocked regardless of permissions: claude.ai partner MCPs bypass our MCPActivate gate, and the CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) is replaced by our SpawnAgent MCP.
HARD_BLOCKED_TOOLS: List[str] = ["mcp__claude_ai_*", "Agent", "Task"]
@typechecked
def merge_hard_blocked_tools(effective_disallowed: List[str]) -> List[str]:
"""The SDK deny list = the computed per-turn denies PLUS the unconditional hard blocks. A plain assignment here once silently discarded effective_disallowed (Cron*/Skill/web-swap/per-tool MCP denies), leaving the runtime gate as the only wall; merge, never overwrite."""
return effective_disallowed + [t for t in HARD_BLOCKED_TOOLS if t not in effective_disallowed]
# `manager` is the AgentManager; it isn't annotated because typing it would import agent_manager back into a module agent_manager already imports (a cycle). Same reason self is never annotated.
@typechecked
@@ -153,6 +153,7 @@ class SessionLifecycle(AgentManagerProtocol):
limit: int = 20,
offset: int = 0,
dashboard_id: Optional[str] = None,
closed_only: bool = False,
) -> Dict:
"""Return paginated, optionally filtered summaries of closed sessions."""
all_data = load_all_session_data()
@@ -161,6 +162,9 @@ class SessionLifecycle(AgentManagerProtocol):
q_lower = q.strip().lower()
history = []
for sid, data in all_data:
# The boot fetch wants CLOSED sessions only: open ones landing in the client's history map made its resurrection gate swallow their terminal frames. Search keeps the full pool (open sessions on other dashboards are reachable nowhere else).
if closed_only and not data.get("closed_at"):
continue
if dashboard_id and data.get("dashboard_id") != dashboard_id:
continue
if q_lower:
@@ -1,22 +1,19 @@
"""The SDK PostToolUse hook, lifted out of the agent loop. Runs after every tool call:
records per-tool latency, normalizes the raw tool response into displayable text, re-renders
view-builder writes (and drains build errors), materializes a spawned Agent sub-session into
the manager registry, spills oversized results to disk, and broadcasts the tool_result message.
view-builder writes (and drains build errors), spills oversized results to disk, and
broadcasts the tool_result message.
Operates on the HookContext (its `sessions` is the manager's live registry). The dict returns
and payloads are the SDK hook protocol / existing message shapes, not internal models."""
import asyncio
import logging
import time
from datetime import datetime
from typing import Dict
from uuid import uuid4
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.core.models import Message
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
from backend.apps.agents.manager.session.history_compaction import (
truncate_large_tool_result,
wrap_platform_note,
@@ -145,68 +142,7 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex
if elapsed_ms is not None:
result_payload["elapsed_ms"] = elapsed_ms
if hook_tool_name == "Agent":
tool_input = input_data.get("tool_input", {})
agent_prompt = tool_input.get("prompt", tool_input.get("task", ""))
sub_text = content
sub_cost = 0.0
sub_tokens = {"input": 0, "output": 0}
sub_model = session.model
if isinstance(raw_response, dict):
blocks = raw_response.get("content")
if isinstance(blocks, list):
parts = [
b.get("text", "")
for b in blocks
if isinstance(b, dict) and b.get("type") == "text"
]
if parts:
sub_text = "\n".join(parts) if len(parts) > 1 else parts[0]
elif isinstance(raw_response.get("text"), str):
sub_text = raw_response["text"]
usage = raw_response.get("usage", {})
if isinstance(usage, dict):
sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
# Pill-only lane: NEW (uncached) input, excludes the cached static prefix so the bubble shows what this turn added.
sub_tokens["input_fresh"] = usage.get("input_tokens", 0)
sub_tokens["output"] = usage.get("output_tokens", 0)
if raw_response.get("total_cost_usd"):
sub_cost = raw_response["total_cost_usd"]
if raw_response.get("model"):
sub_model = raw_response["model"]
sub_session_id = uuid4().hex
sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent"
# Subagent context isolation invariant (Phase 3, Layer P): children DO NOT inherit the parent's active_mcps or compaction state. They start with the AgentSession defaults (empty lists). Reasoning: - Security: a parent that activated Gmail shouldn't leak Gmail tools to a subagent doing an unrelated task. The user only approved Gmail for the parent. - Token cost: subagents typically have a narrow task, they don't need the parent's full activated set. - Failure isolation: if the parent compacted history, the subagent shouldn't inherit a summary it can't re-expand. If a subagent ever needs a parent activation, the user must approve it explicitly via MCPActivate inside the subagent session, same gate as a fresh top-level chat.
sub_session = AgentSession(
id=sub_session_id,
name=sub_name,
status="completed",
model=sub_model,
mode="sub-agent",
cwd=session.cwd,
created_at=datetime.now(),
cost_usd=sub_cost,
tokens=sub_tokens,
messages=[
Message(role="user", content=agent_prompt, branch_id="main"),
Message(role="assistant", content=sub_text, branch_id="main"),
],
dashboard_id=session.dashboard_id,
parent_session_id=session_id,
# Explicit empty list (matches the model default) so the invariant is visible at the spawn site rather than relying on the field's default_factory.
active_mcps=[],
)
apply_context_window(sub_session)
ctx.sessions[sub_session_id] = sub_session
await ws_manager.broadcast_global("agent:status", {
"session_id": sub_session_id,
"status": sub_session.status,
"session": sub_session.model_dump(mode="json"),
})
result_payload["sub_session_id"] = sub_session_id
# The CLI's built-in Agent/Task sub-agent tool is hard-blocked (disallowed_tools) and replaced by the SpawnAgent MCP route, which materializes real child sessions itself; no per-tool branch needed here anymore.
result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id)
# Spill oversized tool results to per-session disk storage. The replacement keeps the first 4KB inline so the model retains some signal; the rest lives on disk for the UI to surface in the compaction drawer. Crucially this happens at *write* time (before the next turn ships history to the SDK) so the bloat never re-enters context.
try:
+2
View File
@@ -20,6 +20,8 @@ MODEL_TIERS: dict[str, tuple[int, int, int]] = {
"claude-opus-4-5": (5, 2, 5),
"claude-opus-4": (5, 2, 5),
"anthropic/claude-opus-4": (5, 2, 5),
"claude-sonnet-5": (5, 4, 3),
"anthropic/claude-sonnet-5": (5, 4, 3),
"claude-sonnet-4-6": (4, 4, 3),
"claude-sonnet-4.6": (4, 4, 3),
"anthropic/claude-sonnet-4.6": (4, 4, 3),
@@ -93,6 +93,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
# gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes.
# GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only
# (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out
# $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough,
# and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and
# OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway).
# No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers
# once 9Router can translate to /v1/responses AND the model is generally available.
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5",
"api": "openai", "reasoning": True, "route": "api"},
+54 -3
View File
@@ -295,7 +295,7 @@ def send_response(id_, result=None, error=None):
sys.stdout.flush()
def _call(method: str, path: str, body=None) -> dict:
def _call(method: str, path: str, body=None, timeout: int = 30) -> dict:
url = BACKEND_BASE + path
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"}
@@ -303,7 +303,7 @@ def _call(method: str, path: str, body=None) -> dict:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode() or "null") or {}
except urllib.error.HTTPError as e:
body_err = e.read().decode() if e.fp else str(e)
@@ -370,7 +370,12 @@ def handle_list(_args: dict) -> dict:
title = w.get("title", "(untitled)")
wid = w.get("id", "")
state = "ON" if enabled else "off"
lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid})")
desc = (w.get("description") or "").strip().replace("\n", " ")
if len(desc) > 120:
desc = desc[:117] + "..."
invocable = " [agent-invocable]" if w.get("exposed_as_tool") else ""
suffix = f" - {desc}" if desc else ""
lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid}){invocable}{suffix}")
return _ok("\n".join(lines))
@@ -560,7 +565,53 @@ def handle_suggest_convert_to_workflow(args: dict) -> dict:
return {"content": [{"type": "text", "text": result}]}
# Matches the backend's INVOKE_WAIT_TIMEOUT_S; the HTTP call outlives the run wait by a margin.
INVOKE_WAIT_TIMEOUT_S = 15 * 60
TOOLS.append({
"name": "InvokeWorkflow",
"description": (
"Run one of the user's saved workflows and WAIT for its result (status + full transcript). "
"Only workflows the user marked agent-invocable on the Actions page can be run; "
"ListScheduledWorkflows marks those with [agent-invocable]. Pass the workflow id or exact title. "
"Long workflows may take minutes; the call blocks until the run finishes (15 min cap)."
),
"inputSchema": {
"type": "object",
"properties": {
"workflow": {"type": "string", "description": "Workflow id or exact title"},
},
"required": ["workflow"],
},
})
def handle_invoke_workflow(args: dict) -> dict:
ident = str(args.get("workflow") or "").strip()
if not ident:
return _err("workflow (id or exact title) is required")
r = _call("GET", "/list")
if "_error" in r:
return _err(r["_error"])
exposed = [w for w in r.get("workflows", []) if w.get("exposed_as_tool")]
match = next((w for w in exposed if w.get("id") == ident), None) or next(
(w for w in exposed if (w.get("title") or "").strip().lower() == ident.lower()), None)
if not match:
names = ", ".join(f"{w.get('title')} (id: {w.get('id')})" for w in exposed) or "(none)"
return _err(f"No agent-invocable workflow matches '{ident}'. Invocable workflows: {names}")
res = _call("POST", f"/{match['id']}/invoke", body={}, timeout=INVOKE_WAIT_TIMEOUT_S + 30)
if "_error" in res:
return _err(res["_error"])
if res.get("timed_out"):
return _ok(f"Run of '{match.get('title')}' is still going after 15 minutes; it continues in the background. Check the workflow's History for the outcome.")
status = res.get("status") or "unknown"
err_line = f"\nError: {res.get('error')}" if res.get("error") else ""
transcript = res.get("transcript") or "(no transcript)"
return _ok(f"Workflow '{match.get('title')}' run {status}.{err_line}\n\n=== RUN TRANSCRIPT ===\n{transcript}\n=== END TRANSCRIPT ===")
HANDLERS = {
"InvokeWorkflow": handle_invoke_workflow,
"ScheduleWorkflow": handle_schedule_workflow,
"ListScheduledWorkflows": handle_list,
"UpdateScheduledWorkflow": handle_update,
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing the SpawnAgent tool; proxies to /api/spawn-agent/run.
Replaces the CLI's built-in Agent tool (blocked in RunOptions): that schema drags
description/subagent_type/model/isolation along, and its subagent types resolve to
models our router setups can't serve. This one takes prompt + run_in_background,
nothing else; the child runs as a real OpenSwarm session card on the dashboard."""
import json
import sys
import os
import urllib.request
import urllib.error
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/spawn-agent/run"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
TOOLS = [
{
"name": "SpawnAgent",
"description": (
"Spawn a sub-agent to handle a task. The sub-agent runs as its own "
"agent session (visible on the dashboard) with the same working "
"directory and model as you. By default this blocks until the "
"sub-agent finishes and returns its final answer; set "
"run_in_background=true to return immediately and let it work on "
"its own, its progress and result appear on its dashboard card."
),
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": (
"The task for the sub-agent. Include all context it "
"needs; it does not see your conversation."
),
},
"run_in_background": {
"type": "boolean",
"description": (
"true = return immediately with the sub-agent's session "
"id instead of waiting for its result."
),
},
},
"required": ["prompt"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(prompt: str, run_in_background: bool) -> dict:
payload = json.dumps({
"prompt": prompt,
"run_in_background": run_in_background,
"parent_session_id": PARENT_SESSION_ID,
"dashboard_id": DASHBOARD_ID,
}).encode()
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(
BACKEND_URL,
data=payload,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=1800) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name != "SpawnAgent":
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
prompt = arguments.get("prompt", "")
run_in_background = bool(arguments.get("run_in_background", False))
if not prompt:
return {"content": [{"type": "text", "text": "Error: prompt is required"}], "isError": True}
result = call_backend(prompt, run_in_background)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
sid = result.get("session_id", "")
if run_in_background:
return {"content": [{"type": "text", "text": (
f"Spawned background sub-agent (session: {sid}). It is working on its own "
"dashboard card; its result will appear there. Do not wait for it."
)}]}
response = result.get("response", "No response from sub-agent.")
cost = result.get("cost_usd", 0)
lines = [f"**Sub-Agent Result** (session: {sid})"]
if cost > 0:
lines.append(f"*Cost: ${cost:.4f}*")
lines.append("")
lines.append(response)
return {"content": [{"type": "text", "text": "\n".join(lines)}]}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-spawn-agent",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
-11
View File
@@ -14,17 +14,6 @@ from jsonschema import validate as schema_validate, ValidationError as SchemaVal
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
"haiku": "claude-haiku-4-5-20251001",
}
def resolve_model(short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def get_anthropic_client(api_model: str | None = None):
"""Create an AsyncAnthropic client using the API key from app settings.
-2
View File
@@ -28,8 +28,6 @@ from backend.apps.outputs.view_builder_templates import (
from backend.apps.settings.settings import load_settings
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
from backend.apps.outputs.html_inject import (
MODEL_MAP,
resolve_model,
get_anthropic_client,
validate_against_schema,
build_data_injection,
+21
View File
@@ -21,8 +21,10 @@ import platform
from collections import Counter
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Literal, Optional
from fastapi import Body
from pydantic import BaseModel, ConfigDict
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
@@ -512,6 +514,25 @@ async def post_event(body: dict):
return {"ok": True}
class UpdaterEventBody(BaseModel):
model_config = ConfigDict(validate_assignment=True)
kind: Literal["idle_install"]
staged_version: Optional[str] = None
@service.router.post("/updater-event")
async def post_updater_event(body: UpdaterEventBody):
"""Electron main reports updater milestones (today just the evergreen idle install) so fleet convergence shows up in analytics logs instead of being inferred."""
from backend.apps.service.analytics.client import get_analytics_client
client = get_analytics_client()
if client is not None:
try:
client.logs.write(tag="updater", subtag=body.kind, data={"staged_version": body.staged_version or "", "app_version": APP_VERSION})
except Exception:
pass
return {"ok": True}
@service.router.get("/spool/count")
async def spool_count():
from backend.apps.service import buffer
+16 -12
View File
@@ -10,18 +10,22 @@ import os
def read_app_version() -> str:
# Preferred: Electron's main process injects this when spawning the backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable in packaged builds because it comes from app.getVersion() rather than path-based file resolution.
env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
if env_v:
return env_v
# Fallback: read electron/package.json via relative path. Works in `bash run.sh` dev mode where the repo layout is intact, but FAILS in packaged dmg/exe builds because electron/package.json isn't shipped into Resources/; which made every shipped install report app_version="unknown" pre-fix. Kept for backward compatibility with dev runs and as a safety net if the env var is ever unset.
try:
p_here = os.path.dirname(os.path.abspath(__file__))
p_repo = os.path.dirname(os.path.dirname(os.path.dirname(p_here)))
p_pkg = os.path.join(p_repo, "electron", "package.json")
with open(p_pkg, encoding="utf-8") as p_f:
return json.load(p_f).get("version", "unknown")
except (OSError, ValueError, KeyError):
return "unknown"
base = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
if not base:
# Fallback: read electron/package.json via relative path. Works in `bash run.sh` dev mode where the repo layout is intact, but FAILS in packaged dmg/exe builds because electron/package.json isn't shipped into Resources/; which made every shipped install report app_version="unknown" pre-fix. Kept for backward compatibility with dev runs and as a safety net if the env var is ever unset.
try:
p_here = os.path.dirname(os.path.abspath(__file__))
p_repo = os.path.dirname(os.path.dirname(os.path.dirname(p_here)))
p_pkg = os.path.join(p_repo, "electron", "package.json")
with open(p_pkg, encoding="utf-8") as p_f:
base = json.load(p_f).get("version", "unknown")
except (OSError, ValueError, KeyError):
base = "unknown"
# A dev/hackathon cohort (OPENSWARM_APP_CHANNEL=dev) reports e.g. "1.5.8-dev" so its events stay filterable from real installs; prod or unset keeps the bare version.
channel = os.environ.get("OPENSWARM_APP_CHANNEL", "").strip().lower()
if channel and channel != "prod":
return f"{base}-{channel}"
return base
APP_VERSION = read_app_version()
+2 -2
View File
@@ -16,8 +16,8 @@ DEFAULT_SYSTEM_PROMPT = (
"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"
"## Tool Priority\n"
"1. Connected MCP tools; fastest and most reliable. Use ToolSearch to discover "
"what integrations are available if you're unsure.\n"
"1. Connected MCP tools; fastest and most reliable. To reach an integration you "
"don't already see, use MCPSearch then MCPActivate; never ToolSearch for it.\n"
"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"
+2 -2
View File
@@ -126,9 +126,9 @@ def build_manifest(root_type: EntityType, root_id: str) -> Manifest:
return p_assemble(root_type, root_id)[0]
def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]:
def build_bundle(root_type: EntityType, root_id: str, allow_file_secrets: bool = False) -> tuple[bytes, str]:
manifest, payloads, files = p_assemble(root_type, root_id)
raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files)
raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files, allow_file_secrets=allow_file_secrets)
return raw, manifest.root.name
+10 -37
View File
@@ -1,8 +1,5 @@
"""WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule
shape, actions, model). The workflow store lives on the eric/workflow branch and
is NOT on eric/dev yet, so every store touch is lazy: on a build without it,
export finds nothing and import fails with a clear message, and the module still
imports cleanly. It lights up the moment the workflow forward-port lands.
shape, actions, model).
Safety: an imported workflow must never silently start running on someone else's
machine, so the schedule is forced off on import (the importer re-arms it). The
@@ -12,6 +9,8 @@ from __future__ import annotations
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
from backend.apps.swarm.models import EntityType, Requirement, RequirementKind
from backend.apps.workflows import storage
from backend.apps.workflows.models import Workflow
P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"}
@@ -52,10 +51,7 @@ class WorkflowExportable:
@classmethod
def load(cls, local_id: str) -> "WorkflowExportable | None":
store = p_store()
if store is None:
return None
wf = store.get_workflow(local_id)
wf = storage.get_workflow(local_id)
if wf is None:
return None
data = wf.model_dump(mode="json")
@@ -92,38 +88,15 @@ class WorkflowExportable:
@classmethod
def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str:
store = p_store()
model = p_model()
if store is None or model is None:
from backend.apps.swarm.ziputil import BundleError
raise BundleError("this build doesn't support workflows yet; please update OpenSwarm")
clean = sanitize_workflow(payload)
clean.pop("id", None) # fresh id via the model's default_factory
wf = model(**clean)
store.save_workflow(wf)
wf = Workflow(**clean)
storage.save_workflow(wf)
return wf.id
@classmethod
def rollback(cls, local_id: str) -> None:
store = p_store()
if store is not None:
try:
store.delete_workflow(local_id)
except Exception:
pass
def p_store():
try:
from backend.apps.workflows import storage
return storage
except Exception:
return None
def p_model():
try:
from backend.apps.workflows.models import Workflow
return Workflow
except Exception:
return None
try:
storage.delete_workflow(local_id)
except Exception:
pass
+2
View File
@@ -104,6 +104,8 @@ class ReviewSummary(BaseModel):
class ExportRequest(BaseModel):
type: EntityType
id: str
# User-confirmed "export anyway": skips the file-content secret heuristic on direct download only; denied payload fields stay blocked.
allow_secrets: bool = False
class ExportPreflightResponse(BaseModel):
+1 -1
View File
@@ -71,7 +71,7 @@ async def export_preflight(body: ExportRequest) -> ExportPreflightResponse:
@swarm.router.post("/export")
async def export_bundle(body: ExportRequest) -> Response:
try:
raw, name = closure.build_bundle(body.type, body.id)
raw, name = closure.build_bundle(body.type, body.id, allow_file_secrets=body.allow_secrets)
except BundleError as e:
raise HTTPException(status_code=400, detail=str(e))
fname = closure.swarm_filename(name)
+12 -8
View File
@@ -37,21 +37,25 @@ def p_content_digest(entries: dict[str, bytes]) -> str:
return h.hexdigest()
def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes:
def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes], allow_file_secrets: bool = False) -> bytes:
"""payloads: bundle_id -> JSON payload (-> entities/<bid>/payload.json).
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>)."""
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>).
allow_file_secrets is a user-confirmed override for the FILE-content heuristic only
(workspace code trips it on look-alike strings); denied payload fields are our own
credential store and are never exportable, override or not."""
for bid, payload in payloads.items():
leaked = find_denied_keys(payload)
if leaked:
raise BundleError(
f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}"
)
leaky_files = find_secrets_in_files(files)
if leaky_files:
raise BundleError(
f"refusing to export: a secret-shaped value is in {leaky_files[0]}; "
"remove it (use an environment variable) and try again"
)
if not allow_file_secrets:
leaky_files = find_secrets_in_files(files)
if leaky_files:
raise BundleError(
f"refusing to export: a secret-shaped value is in {leaky_files[0]}; "
"remove it (use an environment variable) and try again"
)
entries: dict[str, bytes] = {}
for bid, payload in payloads.items():
entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8")
+5
View File
@@ -85,6 +85,8 @@ class Workflow(BaseModel):
deleted_at: Optional[datetime] = None
system_prompt: Optional[str] = None
use_synced_prompt: bool = True
# Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page).
exposed_as_tool: bool = False
steps: list[WorkflowStep] = Field(default_factory=list)
actions: ActionsConfig = Field(default_factory=ActionsConfig)
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
@@ -162,6 +164,8 @@ class WorkflowCreate(BaseModel):
color: Optional[str] = None
system_prompt: Optional[str] = None
use_synced_prompt: bool = True
# Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page).
exposed_as_tool: bool = False
steps: list[WorkflowStep] = Field(default_factory=list)
actions: ActionsConfig = Field(default_factory=ActionsConfig)
schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
@@ -198,6 +202,7 @@ class WorkflowUpdate(BaseModel):
color: Optional[str] = None
system_prompt: Optional[str] = None
use_synced_prompt: Optional[bool] = None
exposed_as_tool: Optional[bool] = None
steps: Optional[list[WorkflowStep]] = None
actions: Optional[ActionsConfig] = None
schedule: Optional[ScheduleConfig] = None
+12 -4
View File
@@ -322,10 +322,11 @@ async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None:
logger.exception("scheduler fire failed for workflow=%s", wf.id)
def _seconds_until_next() -> float:
# While globally paused, _tick no-ops and never rolls next_run_at forward, so an overdue slot would otherwise spin this loop at the 1s floor. Resume calls kick(), so idling the full interval here costs nothing.
def seconds_to_next_fire() -> Optional[float]:
"""Seconds until the soonest enabled scheduled workflow fires; None when nothing is
queued or scheduling is globally paused. Also feeds the desktop's idle-update gate."""
if storage.get_paused():
return 60.0
return None
now_utc = datetime.now(timezone.utc)
soonest: Optional[datetime] = None
for wf in storage.list_workflows():
@@ -337,8 +338,15 @@ def _seconds_until_next() -> float:
if soonest is None or nra < soonest:
soonest = nra
if soonest is None:
return None
return max(0.0, (soonest - now_utc).total_seconds())
def _seconds_until_next() -> float:
# While globally paused, _tick no-ops and never rolls next_run_at forward, so an overdue slot would otherwise spin this loop at the 1s floor. Resume calls kick(), so idling the full interval here costs nothing.
delta = seconds_to_next_fire()
if delta is None:
return 60.0
delta = (soonest - now_utc).total_seconds()
return max(1.0, min(delta, 60.0))
+35
View File
@@ -1438,6 +1438,41 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
return {"run_id": "", "status": None, "error": None}
# Bounded so a runaway workflow can't pin the calling agent's tool call forever; on timeout the run keeps going and lands in History.
INVOKE_WAIT_TIMEOUT_S = 15 * 60
@workflows.router.post("/{workflow_id}/invoke")
async def invoke_workflow(workflow_id: str):
"""Run a workflow AND wait for the result: the InvokeWorkflow MCP tool's backend. Unlike /run
(fire-and-forget for the History pane), the caller here is an agent that needs the outcome inline,
so this awaits the executor and returns status + transcript. Gated on the per-workflow opt-in."""
wf = storage.get_workflow(workflow_id)
if not wf or wf.deleted_at:
raise HTTPException(status_code=404, detail="Workflow not found")
if not wf.exposed_as_tool:
raise HTTPException(status_code=403, detail="Workflow is not agent-invocable (enable it on the Actions page)")
task = asyncio.create_task(executor.execute(wf, triggered_by="manual"))
try:
# shield: a timeout must not cancel the run, it continues and History has it.
run = await asyncio.wait_for(asyncio.shield(task), timeout=INVOKE_WAIT_TIMEOUT_S)
except asyncio.TimeoutError:
return {"run_id": "", "status": "running", "error": None, "cost_usd": 0.0, "transcript": "",
"timed_out": True}
transcript = ""
if run.session_id:
from backend.apps.agents.agent_manager import agent_manager
sess = agent_manager.sessions.get(run.session_id)
if sess is None:
try:
sess = await agent_manager.resume_session(run.session_id)
except ValueError:
sess = None
transcript = p_render_test_transcript(getattr(sess, "messages", []) or []) if sess else ""
return {"run_id": run.id, "status": run.status, "error": run.error,
"cost_usd": run.cost_usd, "transcript": transcript, "timed_out": False}
def _find_active_run(run_id: str):
"""Locate a currently-running run by id, returning (workflow_id, run)."""
for wf in storage.list_workflows():
+31
View File
@@ -871,6 +871,37 @@ async def settings_meta(action: str, request: Request):
@app.post("/api/spawn-agent/run")
async def spawn_agent_run(request: Request):
"""Spawn a fresh sub-agent session for the SpawnAgent tool.
Called by the spawn_agent_mcp_server stdio subprocess."""
body = await request.json()
prompt = body.get("prompt", "")
parent_session_id = body.get("parent_session_id", "")
dashboard_id = body.get("dashboard_id", "")
run_in_background = bool(body.get("run_in_background", False))
if not prompt:
return JSONResponse({"error": "prompt is required"}, status_code=400)
if not parent_session_id:
return JSONResponse({"error": "parent_session_id is required"}, status_code=400)
try:
from backend.apps.agents.agent_manager import agent_manager
result = await agent_manager.spawn_agent(
prompt=prompt,
parent_session_id=parent_session_id,
dashboard_id=dashboard_id or None,
run_in_background=run_in_background,
)
return JSONResponse(result)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=404)
except Exception as e:
logger.exception("spawn_agent_run failed")
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/invoke-agent/run")
async def invoke_agent_run(request: Request):
"""Fork an existing agent session and send it a new message.
+6 -4
View File
@@ -56,7 +56,9 @@ fi
# (compared via `dir in path.parents`). So we pass ABSOLUTE paths to
# the dirs we want to exclude — those are the only patterns uvicorn's
# WatchFilesReload actually honors for "anywhere under this tree".
echo "Starting backend server on http://0.0.0.0:8324 ..."
# Dev only: OPENSWARM_PORT lets a parallel worktree bind its own backend port instead of colliding on 8324. Packaged builds never set it.
BACKEND_PORT="${OPENSWARM_PORT:-8324}"
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT} ..."
cd "$PROJECT_ROOT_ABSPATH"
UVICORN_EXCLUDE_ARGS=(--reload-exclude '*.pyc')
@@ -79,10 +81,10 @@ done
# launcher does). Packaged builds leave it unset → fast, lean,
# single-process uvicorn.
if [[ "${OPENSWARM_DEV:-}" == "1" ]]; then
echo "OPENSWARM_DEV=1 detected running uvicorn with --reload."
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
echo "OPENSWARM_DEV=1 detected, running uvicorn with --reload."
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$BACKEND_PORT" --reload \
--reload-dir "$BACKEND_DIR_ABSPATH" \
"${UVICORN_EXCLUDE_ARGS[@]}"
else
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$BACKEND_PORT"
fi
+59
View File
@@ -0,0 +1,59 @@
import asyncio
from pytest import MonkeyPatch
from backend.apps.agents import agents as agents_module
from backend.apps.agents.core.models import AgentSession, Message
def test_session_list_item_replaces_messages_with_compact_metadata(
monkeypatch: MonkeyPatch,
) -> None:
first_prompt = "p" * 250
last_reply = "r" * 150
session = AgentSession(
name="Test session",
messages=[
Message(role="system", content="system"),
Message(role="user", content=first_prompt),
Message(role="assistant", content=last_reply),
],
)
monkeypatch.setattr(
agents_module.agent_manager,
"get_all_sessions",
lambda dashboard_id=None: [session],
)
item = asyncio.run(agents_module.list_sessions())["sessions"][0]
assert item["messages"] == []
assert item["message_count"] == 3
assert item["first_user_message"] == first_prompt[:200]
assert item["last_message_preview"] == last_reply[:120]
def test_session_list_item_handles_empty_and_non_text_content(
monkeypatch: MonkeyPatch,
) -> None:
sessions = [
AgentSession(name="Empty"),
AgentSession(
name="Images",
messages=[Message(role="user", content=[{"type": "image"}])],
),
]
monkeypatch.setattr(
agents_module.agent_manager,
"get_all_sessions",
lambda dashboard_id=None: sessions,
)
empty, non_text = asyncio.run(agents_module.list_sessions())["sessions"]
assert empty["messages"] == []
assert empty["message_count"] == 0
assert empty["first_user_message"] == ""
assert empty["last_message_preview"] == ""
assert non_text["message_count"] == 1
assert non_text["first_user_message"] == ""
assert non_text["last_message_preview"] == ""
+68
View File
@@ -0,0 +1,68 @@
"""The recovery-card wedge fix: when a card is declared dead, its webview must be
torn down (renderer unmount + layout removal) BEFORE recovery spawns a fresh card,
so two heavy pages never co-exist and starve the renderer. Pins evict_dead_card."""
import asyncio
import backend.apps.agents.browser.browser_agent as ba
class FakeLayout:
def __init__(self, cards):
self.browser_cards = cards
class FakeDash:
def __init__(self, cards):
self.layout = FakeLayout(cards)
self.updated_at = None
def p_patch(monkeypatch, cards):
broadcasts = []
saved = []
async def fake_broadcast(event, data):
broadcasts.append((event, data))
dash = FakeDash(cards)
monkeypatch.setattr(ba, "P_EVICT_SETTLE_S", 0, raising=True) # don't pay the renderer-settle wait in a unit test
monkeypatch.setattr(ba.ws_manager, "broadcast_global", fake_broadcast, raising=True)
import backend.apps.dashboards.dashboards as dmod
monkeypatch.setattr(dmod, "load", lambda did: dash, raising=True)
monkeypatch.setattr(dmod, "save", lambda d: saved.append(d), raising=True)
return broadcasts, saved, dash
def test_evict_broadcasts_unmount_and_removes_from_layout(monkeypatch):
broadcasts, saved, dash = p_patch(monkeypatch, {"browser-dead": FakeCard("sess-1"), "browser-keep": FakeCard("sess-1")})
ba.ACTIVE_AGENT_CARDS.add("browser-dead")
asyncio.run(ba.evict_dead_card("dash-1", "browser-dead"))
# the renderer is told to unmount exactly the dead card
assert ("dashboard:browser_card_evict", {"dashboard_id": "dash-1", "browser_id": "browser-dead"}) in broadcasts
# it's gone from the persisted layout, its neighbor is untouched
assert "browser-dead" not in dash.layout.browser_cards
assert "browser-keep" in dash.layout.browser_cards
assert saved # the layout was persisted
assert "browser-dead" not in ba.ACTIVE_AGENT_CARDS
def test_evict_without_a_dashboard_deletes_nothing(monkeypatch):
# No dashboard = ownership unverifiable = fail SAFE: never unmount or delete
# what might be the user's card; the reuse-skip alone handles it.
broadcasts, saved, _ = p_patch(monkeypatch, {})
asyncio.run(ba.evict_dead_card("", "browser-x"))
assert not broadcasts and not saved
class FakeCard:
def __init__(self, spawned_by=None):
self.spawned_by = spawned_by
def test_user_card_is_never_evicted(monkeypatch):
"""A wedged USER card (no spawned_by) must never be deleted out from under the
user; reuse-skip is the whole remedy. Only agent-spawned cards evict."""
broadcasts, saved, dash = p_patch(monkeypatch, {"browser-user": FakeCard(None)})
asyncio.run(ba.evict_dead_card("dash-1", "browser-user"))
assert not broadcasts and not saved
assert "browser-user" in dash.layout.browser_cards
+63
View File
@@ -0,0 +1,63 @@
"""API surface of the desktop idle-update gate: the /agents/activity lookahead
fields Electron polls before a silent install, and the /service/updater-event
breadcrumb it fires when one happens."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from pydantic import ValidationError
@pytest.mark.asyncio
async def test_activity_reports_active_and_next_run(monkeypatch):
from backend.apps.agents import agents as agents_module
monkeypatch.setattr(agents_module.agent_manager, "tasks", {})
monkeypatch.setattr("backend.apps.workflows.scheduler.seconds_to_next_fire", lambda: 123.0)
out = await agents_module.agent_activity()
assert out == {"active": 0, "next_run_in_s": 123.0}
@pytest.mark.asyncio
async def test_activity_lookahead_fails_open(monkeypatch):
from backend.apps.agents import agents as agents_module
def boom():
raise RuntimeError("lookahead broke")
monkeypatch.setattr(agents_module.agent_manager, "tasks", {})
monkeypatch.setattr("backend.apps.workflows.scheduler.seconds_to_next_fire", boom)
out = await agents_module.agent_activity()
assert out["next_run_in_s"] is None
@pytest.mark.asyncio
async def test_updater_event_writes_analytics_log(monkeypatch):
from backend.apps.service import service as service_module
from backend.apps.service.analytics import client as analytics_client_module
logs = Mock()
monkeypatch.setattr(analytics_client_module, "get_analytics_client", lambda: SimpleNamespace(logs=logs))
body = service_module.UpdaterEventBody(kind="idle_install", staged_version="1.5.9")
out = await service_module.post_updater_event(body)
assert out == {"ok": True}
kw = logs.write.call_args.kwargs
assert kw["tag"] == "updater"
assert kw["subtag"] == "idle_install"
assert kw["data"]["staged_version"] == "1.5.9"
@pytest.mark.asyncio
async def test_updater_event_survives_missing_client(monkeypatch):
from backend.apps.service import service as service_module
from backend.apps.service.analytics import client as analytics_client_module
monkeypatch.setattr(analytics_client_module, "get_analytics_client", lambda: None)
out = await service_module.post_updater_event(service_module.UpdaterEventBody(kind="idle_install"))
assert out == {"ok": True}
def test_updater_event_kind_is_constrained():
from backend.apps.service.service import UpdaterEventBody
with pytest.raises(ValidationError):
UpdaterEventBody(kind="anything_else")
@@ -0,0 +1,59 @@
"""The InvokeWorkflow MCP tool: resolves id-or-title among EXPOSED workflows only,
lists the invocable set on a miss, and relays the backend's run result."""
import backend.apps.agents.schedule_mcp_server as srv
def p_patch_call(monkeypatch, workflows, invoke_result=None):
calls = []
def p_fake_call(method, path, body=None, timeout=30):
calls.append((method, path))
if path == "/list":
return {"workflows": workflows}
if path.endswith("/invoke"):
return invoke_result or {}
return {}
monkeypatch.setattr(srv, "_call", p_fake_call)
return calls
def test_resolves_exact_title_among_exposed_only(monkeypatch):
wfs = [
{"id": "w1", "title": "Daily brief", "exposed_as_tool": True},
{"id": "w2", "title": "Secret ops", "exposed_as_tool": False},
]
calls = p_patch_call(monkeypatch, wfs, {"status": "success", "error": None, "transcript": "did it", "timed_out": False})
out = srv.handle_invoke_workflow({"workflow": "daily brief"})
assert not out.get("isError")
text = out["content"][0]["text"]
assert "success" in text and "did it" in text
assert ("POST", "/w1/invoke") in calls
def test_unexposed_workflow_is_not_invocable(monkeypatch):
wfs = [{"id": "w2", "title": "Secret ops", "exposed_as_tool": False}]
p_patch_call(monkeypatch, wfs)
out = srv.handle_invoke_workflow({"workflow": "Secret ops"})
assert out.get("isError")
def test_miss_lists_the_invocable_set(monkeypatch):
wfs = [{"id": "w1", "title": "Daily brief", "exposed_as_tool": True}]
p_patch_call(monkeypatch, wfs)
out = srv.handle_invoke_workflow({"workflow": "nope"})
assert out.get("isError")
assert "Daily brief" in out["content"][0]["text"]
def test_timeout_reports_background_continuation(monkeypatch):
wfs = [{"id": "w1", "title": "Daily brief", "exposed_as_tool": True}]
p_patch_call(monkeypatch, wfs, {"timed_out": True})
out = srv.handle_invoke_workflow({"workflow": "w1"})
assert not out.get("isError")
assert "History" in out["content"][0]["text"]
def test_tool_is_declared_and_dispatchable():
assert any(t["name"] == "InvokeWorkflow" for t in srv.TOOLS)
assert srv.HANDLERS["InvokeWorkflow"] is srv.handle_invoke_workflow
+120
View File
@@ -0,0 +1,120 @@
"""SpawnAgent: the native replacement for the CLI's built-in Agent tool. The child is a
fresh session inheriting the parent's model/cwd/dashboard; sync waits and returns the last
assistant text, background returns immediately. The builtin stays blocked so the model only
ever sees the two-arg schema."""
import asyncio
import json
import subprocess
import sys
from typing import Dict, List, Optional
from pytest import MonkeyPatch, raises
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentSession, Message
def seed_parent() -> AgentSession:
parent = AgentSession(name="parent", model="opus-4-8", cwd="/tmp/pw", dashboard_id="dashX")
agent_manager.sessions[parent.id] = parent
return parent
def test_spawn_agent_sync_returns_child_answer(monkeypatch: MonkeyPatch) -> None:
parent = seed_parent()
async def fake_loop(session_id: str, prompt: str, **kwargs: object) -> None:
s = agent_manager.sessions[session_id]
s.messages.append(Message(role="assistant", content="child says done", branch_id=s.active_branch_id))
s.status = "completed"
monkeypatch.setattr(agent_manager, "run_agent_loop", fake_loop)
result = asyncio.run(agent_manager.spawn_agent(prompt="do the thing", parent_session_id=parent.id))
child = agent_manager.sessions[result["session_id"]]
assert result["response"] == "child says done"
assert child.mode == "sub-agent"
assert child.parent_session_id == parent.id
assert child.model == parent.model
assert child.cwd == parent.cwd
assert child.dashboard_id == "dashX"
assert child.messages[0].role == "user" and child.messages[0].content == "do the thing"
def test_spawn_agent_background_returns_immediately(monkeypatch: MonkeyPatch) -> None:
parent = seed_parent()
started: List[str] = []
async def slow_loop(session_id: str, prompt: str, **kwargs: object) -> None:
started.append(session_id)
await asyncio.sleep(30)
monkeypatch.setattr(agent_manager, "run_agent_loop", slow_loop)
async def run() -> Dict:
result = await asyncio.wait_for(
agent_manager.spawn_agent(prompt="long task", parent_session_id=parent.id, run_in_background=True),
timeout=2.0,
)
await asyncio.sleep(0.05)
agent_manager.tasks[result["session_id"]].cancel()
return result
result = asyncio.run(run())
assert result["background"] is True
assert started == [result["session_id"]]
def test_spawn_agent_unknown_parent_raises() -> None:
with raises(ValueError):
asyncio.run(agent_manager.spawn_agent(prompt="x", parent_session_id="nope-" + "0" * 28))
def test_builtin_subagent_tools_stay_blocked() -> None:
# The CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) must not be offered: out of the catalog AND hard-blocked at the SDK layer.
from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS
from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools
assert "Agent" not in FULL_TOOLS
assert "Task" not in FULL_TOOLS
blocked = merge_hard_blocked_tools([])
assert "Agent" in blocked and "Task" in blocked and "mcp__claude_ai_*" in blocked
def test_effective_disallowed_reaches_the_sdk_deny_list() -> None:
# Regression: a plain assignment once DISCARDED the computed denies (Cron*/Skill/web-swap/per-tool MCP), leaving the runtime gate as the only wall. The merge must keep them AND append the hard blocks, deduped.
from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools
computed = ["CronCreate", "CronList", "CronDelete", "Skill", "mcp__gmail__DeleteEmail", "Task"]
merged = merge_hard_blocked_tools(computed)
for t in computed:
assert t in merged
assert "Agent" in merged and "mcp__claude_ai_*" in merged
assert merged.count("Task") == 1
assert merged[:len(computed)] == computed
def test_spawn_server_schema_is_prompt_plus_background_only() -> None:
from backend.apps.agents import spawn_agent_mcp_server as srv
tool = srv.TOOLS[0]
assert tool["name"] == "SpawnAgent"
assert set(tool["inputSchema"]["properties"].keys()) == {"prompt", "run_in_background"}
assert tool["inputSchema"]["required"] == ["prompt"]
def test_spawn_server_speaks_mcp_stdio() -> None:
from backend.apps.agents import spawn_agent_mcp_server as srv
proc = subprocess.Popen(
[sys.executable, srv.__file__],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
)
try:
msgs = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
]
out, _ = proc.communicate("\n".join(json.dumps(m) for m in msgs) + "\n", timeout=15)
lines = [json.loads(line) for line in out.strip().splitlines()]
assert lines[0]["result"]["serverInfo"]["name"] == "openswarm-spawn-agent"
assert lines[1]["result"]["tools"][0]["name"] == "SpawnAgent"
finally:
proc.kill()
+45
View File
@@ -0,0 +1,45 @@
"""The sub-agent model pin on the 9Router-direct route must come from LIVE router
connections. The run/ split passed a hardcoded empty list, the pin never fired, and every
sub-agent 401'd ("No credentials for provider: anthropic") while the parent turn worked."""
import asyncio
from typing import Dict
from pytest import MonkeyPatch
import backend.apps.agents.manager.configure_provider_env as cpe
from backend.apps.agents.core.models import AgentSession
from backend.apps.settings.models import AppSettings
def run_env_for(connections: list, monkeypatch: MonkeyPatch) -> Dict:
import backend.apps.nine_router as nr_pkg
async def fake_get_providers() -> list:
return connections
monkeypatch.setattr(nr_pkg, "is_running", lambda: True)
monkeypatch.setattr(nr_pkg, "get_providers", fake_get_providers)
session = AgentSession(name="t", model="opus-4-8-cc")
options_kwargs: Dict = {}
asyncio.run(
cpe.configure_provider_env(
options_kwargs, session, "cc/claude-opus-4-8", "anthropic", AppSettings()
)
)
return options_kwargs.get("env", {})
def test_subagent_pin_set_from_live_claude_connection(monkeypatch: MonkeyPatch) -> None:
env = run_env_for([{"provider": "claude", "isActive": True}], monkeypatch)
assert env.get("CLAUDE_CODE_SUBAGENT_MODEL") == "cc/claude-sonnet-4-6"
assert env.get("ANTHROPIC_SMALL_FAST_MODEL") == "cc/claude-haiku-4-5-20251001"
def test_subagent_pin_absent_only_when_no_active_lane(monkeypatch: MonkeyPatch) -> None:
env = run_env_for([], monkeypatch)
assert "CLAUDE_CODE_SUBAGENT_MODEL" not in env
def test_subagent_pin_codex_lane(monkeypatch: MonkeyPatch) -> None:
env = run_env_for([{"provider": "codex", "isActive": True}], monkeypatch)
assert env.get("CLAUDE_CODE_SUBAGENT_MODEL") == "cx/gpt-5.4-mini"
+9
View File
@@ -110,6 +110,15 @@ def test_pack_allows_clean_workspace_file():
assert zipfile.is_zipfile(io.BytesIO(raw))
def test_pack_export_anyway_overrides_file_scan_but_never_denied_keys():
# User-confirmed override ships a flagged workspace FILE (trusted recipient); our own credential fields stay unexportable no matter what.
leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}, allow_file_secrets=True)
assert zipfile.is_zipfile(io.BytesIO(raw))
with pytest.raises(BundleError):
pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}, allow_file_secrets=True)
def test_app_export_drops_machine_env(tmp_path, monkeypatch):
# The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does.
from backend.apps.swarm.entities import apps as appmod
+46
View File
@@ -0,0 +1,46 @@
"""OSW_TOOL_MANIFEST gate: default ships the full claude_code preset; flag-on ships an explicit
FULL_TOOLS list that prunes dead preset built-ins WITHOUT dropping anything OpenSwarm uses. The
manifest MUST keep every built-in in effective_allowed's source set + ToolSearch (so deferred MCP
loading survives); MCP tools ride mcp_servers, not this list."""
import os
from backend.apps.agents.manager.prompt.tool_catalog import (
FULL_TOOLS,
resolve_builtin_tools_option,
)
def test_default_is_the_full_preset(monkeypatch):
monkeypatch.delenv("OSW_TOOL_MANIFEST", raising=False)
assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"}
def test_flag_on_is_the_explicit_full_tools_manifest(monkeypatch):
monkeypatch.setenv("OSW_TOOL_MANIFEST", "1")
out = resolve_builtin_tools_option()
assert isinstance(out, list)
assert out == list(FULL_TOOLS)
# A fresh copy, never the module list itself (a caller mutation must not poison FULL_TOOLS).
assert out is not FULL_TOOLS
def test_manifest_keeps_toolsearch_so_deferred_mcp_loading_survives(monkeypatch):
monkeypatch.setenv("OSW_TOOL_MANIFEST", "1")
out = resolve_builtin_tools_option()
assert "ToolSearch" in out
def test_manifest_keeps_every_core_builtin_openswarm_exposes(monkeypatch):
# These are the built-ins the agent actually uses; none may vanish from the manifest.
monkeypatch.setenv("OSW_TOOL_MANIFEST", "1")
out = resolve_builtin_tools_option()
for core in ("Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"):
assert core in out
def test_only_the_exact_flag_value_flips_it(monkeypatch):
monkeypatch.setenv("OSW_TOOL_MANIFEST", "true") # not "1"
assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"}
monkeypatch.setenv("OSW_TOOL_MANIFEST", "0")
assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"}
+2 -28
View File
@@ -1,7 +1,7 @@
"""Unit coverage for the extracted PostToolUse hook (tool_result_hook). The streaming harness
mocks claude_agent_sdk.query, so it never fires the SDK's PostToolUse hooks; this pins the
behavior directly: a tool result becomes a tool_result message, and an Agent tool spawns a
sub-session into the manager's LIVE registry (the InstanceOf[dict] sharing, the subtle bit)."""
behavior directly: a tool result becomes a tool_result message, and view-builder writes surface
build/console errors into the result. SpawnAgent (the sub-agent path) is pinned in test_spawn_agent.py."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
@@ -42,32 +42,6 @@ async def test_normal_tool_result_appends_message_and_continues():
send.assert_awaited() # the tool_result is broadcast to the UI
@pytest.mark.asyncio
async def test_agent_tool_spawns_subsession_into_live_registry():
registry: dict = {}
ctx = p_ctx(registry)
parent_id = ctx.session_id
raw = {
"content": [{"type": "text", "text": "sub-agent did the work"}],
"usage": {"input_tokens": 7, "output_tokens": 3},
"total_cost_usd": 0.01,
"model": "sonnet",
}
with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()), \
patch.object(tool_result_hook.ws_manager, "broadcast_global", new=AsyncMock()):
out = await tool_result_hook.post_tool_hook(
ctx, {"tool_name": "Agent", "tool_response": raw, "tool_input": {"prompt": "do x"}}, "tu1", None
)
assert out == {"continue_": True}
# exactly one NEW session registered (besides the parent), parented correctly
children = [s for sid, s in registry.items() if sid != parent_id]
assert len(children) == 1
child = children[0]
assert child.parent_session_id == parent_id
assert child.active_mcps == [] # context-isolation invariant: no inherited activations
assert "sub-agent did the work" in str(child.messages[-1].content)
@pytest.mark.asyncio
async def test_view_builder_dep_install_broadcasts_app_deps_changed():
"""An npm install in a view-builder session must tell the app card this turn
+53
View File
@@ -113,3 +113,56 @@ def test_pause_all_and_resume_all_flip_global_flag(make_wf):
assert storage.get_paused() is True
assert _run(resume_all_schedules()) == {"paused": False}
assert storage.get_paused() is False
# ---- InvokeWorkflow (agents run a workflow as a tool and wait for the result) ----
def test_invoke_unknown_workflow_404():
from fastapi import HTTPException
from backend.apps.workflows.workflows import invoke_workflow
with pytest.raises(HTTPException) as ei:
_run(invoke_workflow("nope"))
assert ei.value.status_code == 404
def test_invoke_requires_the_exposed_opt_in(make_wf):
# A workflow the user never opted in must refuse: exposure is the whole permission model here.
from fastapi import HTTPException
from backend.apps.workflows import storage
from backend.apps.workflows.workflows import invoke_workflow
wf = make_wf()
storage.save_workflow(wf)
with pytest.raises(HTTPException) as ei:
_run(invoke_workflow(wf.id))
assert ei.value.status_code == 403
def test_invoke_waits_and_returns_transcript(make_wf, monkeypatch):
from backend.apps.workflows import storage, executor
from backend.apps.workflows.models import WorkflowRun
from backend.apps.workflows.workflows import invoke_workflow
wf = make_wf(exposed_as_tool=True)
storage.save_workflow(wf)
async def p_fake_execute(w, triggered_by="schedule", scheduled_for=None, tested_signature=None):
assert triggered_by == "manual"
return WorkflowRun(workflow_id=w.id, status="success", session_id="sess-invoke-1", cost_usd=0.02)
monkeypatch.setattr(executor, "execute", p_fake_execute)
from backend.apps.agents.agent_manager import agent_manager
class p_FakeMsg:
role = "assistant"
content = "invoked step done"
hidden = False
class p_FakeSess:
messages = [p_FakeMsg()]
monkeypatch.setitem(agent_manager.sessions, "sess-invoke-1", p_FakeSess())
res = _run(invoke_workflow(wf.id))
assert res["status"] == "success"
assert res["run_id"]
assert res["timed_out"] is False
assert "invoked step done" in res["transcript"]
+49
View File
@@ -888,3 +888,52 @@ def test_escalation_noop_for_single_tier():
run = WorkflowRun(workflow_id=wf.id, status="success")
escalation.schedule(wf, run)
assert escalation.status(run.id) is None
# --- idle-update gate lookahead ----------------------------------------------
def test_seconds_to_next_fire_none_when_nothing_queued():
from backend.apps.workflows import storage
from backend.apps.workflows.scheduler import seconds_to_next_fire
storage.init()
assert seconds_to_next_fire() is None
def test_seconds_to_next_fire_reports_soonest_enabled_only():
from backend.apps.workflows import storage
from backend.apps.workflows.scheduler import seconds_to_next_fire
storage.init()
soon = _make_wf()
soon.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=10)
storage.save_workflow(soon)
later = _make_wf()
later.next_run_at = datetime.now(timezone.utc) + timedelta(hours=3)
storage.save_workflow(later)
disabled = _make_wf()
disabled.schedule.enabled = False
disabled.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=1)
storage.save_workflow(disabled)
got = seconds_to_next_fire()
assert got is not None
assert 9 * 60 < got <= 10 * 60
def test_seconds_to_next_fire_clamps_overdue_to_zero():
from backend.apps.workflows import storage
from backend.apps.workflows.scheduler import seconds_to_next_fire
storage.init()
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=5)
storage.save_workflow(wf)
assert seconds_to_next_fire() == 0.0
def test_seconds_to_next_fire_none_while_paused(monkeypatch):
from backend.apps.workflows import storage
from backend.apps.workflows.scheduler import seconds_to_next_fire
storage.init()
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=1)
storage.save_workflow(wf)
monkeypatch.setattr(storage, "get_paused", lambda: True)
assert seconds_to_next_fire() is None
+155
View File
@@ -0,0 +1,155 @@
"""WS efficiency batch: agent:status frames are slimmed (metadata + previews, never the
transcript), GET /sessions returns the WS seq cursor for resume seeding, and /history's
closed_only filter keeps open sessions out of the client's resurrection gate. Each pins a
live-proven failure mode: full transcripts on status frames were replayed stale and rolled
clients backwards, and an open session in the history map had its terminal frame swallowed."""
import asyncio
import pytest
from fastapi.testclient import TestClient
from backend.apps.agents.core.ws_manager import ws_manager, slim_status_data
from backend.apps.agents.core.seq_log import seq_log
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentSession, Message
from backend.main import app
def p_client() -> TestClient:
import backend.auth as auth_mod
if not auth_mod.TOKEN:
import secrets
auth_mod.TOKEN = secrets.token_urlsafe(32)
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
def p_status_data(n_msgs: int = 3) -> dict:
session = {
"id": "s1",
"status": "completed",
"messages": (
[{"role": "user", "content": "first user question here"}]
+ [{"role": "assistant", "content": f"reply {i} " + "x" * 200} for i in range(n_msgs - 1)]
),
"name": "t",
}
return {"session_id": "s1", "status": "completed", "session": session}
def test_slim_drops_transcript_and_adds_previews():
out = slim_status_data("agent:status", p_status_data(3))
sess = out["session"]
assert sess["messages"] == []
assert sess["message_count"] == 3
assert sess["first_user_message"].startswith("first user question")
assert sess["last_message_preview"].startswith("reply 1")
assert len(sess["last_message_preview"]) <= 120
# Original input is not mutated (callers may reuse their dicts).
assert p_status_data(3)["session"]["messages"] != []
def test_slim_leaves_non_status_and_messageless_frames_alone():
msg_data = {"session_id": "s1", "message": {"role": "user", "content": "hello"}}
assert slim_status_data("agent:message", msg_data) is msg_data
no_sess = {"session_id": "s1", "status": "running"}
assert slim_status_data("agent:status", no_sess) is no_sess
class p_FakeWs:
def __init__(self) -> None:
self.frames: list = []
async def send_text(self, s: str) -> None:
import json
self.frames.append(json.loads(s))
def test_send_to_session_slims_status_for_both_socket_kinds_and_the_replay_buffer():
sess_ws, dash_ws = p_FakeWs(), p_FakeWs()
sid = "slimtest-session"
ws_manager.connections[sid] = [sess_ws]
ws_manager.global_connections.append(dash_ws)
try:
asyncio.run(ws_manager.send_to_session(sid, "agent:status", p_status_data(4)))
asyncio.run(ws_manager.send_to_session(sid, "agent:message", {
"session_id": sid, "message": {"role": "assistant", "content": "full text stays"},
}))
for ws in (sess_ws, dash_ws):
status = ws.frames[0]
assert status["data"]["session"]["messages"] == []
assert status["data"]["session"]["message_count"] == 4
msg = ws.frames[1]
assert msg["data"]["message"]["content"] == "full text stays"
# Both sockets got the SAME stamped seq (the frontend dedupes on it).
assert sess_ws.frames[0]["seq"] == dash_ws.frames[0]["seq"]
# The ring buffer stores the slim frame, so replays are slim too.
_, _, events = seq_log.replay(sid, 0)
import json
assert json.loads(events[0])["data"]["session"]["messages"] == []
finally:
ws_manager.connections.pop(sid, None)
ws_manager.global_connections.remove(dash_ws)
seq_log.clear(sid)
def test_get_session_returns_event_seq_cursor():
s = AgentSession(name="t", model="sonnet")
s.messages = [Message(role="user", content="hi")]
agent_manager.sessions[s.id] = s
try:
asyncio.run(ws_manager.send_to_session(s.id, "agent:status", {"session_id": s.id, "status": "running"}))
asyncio.run(ws_manager.send_to_session(s.id, "agent:status", {"session_id": s.id, "status": "completed"}))
res = p_client().get(f"/api/agents/sessions/{s.id}")
assert res.status_code == 200
body = res.json()
assert body["event_seq"] == seq_log.current_seq(s.id) == 2
assert body["messages"][0]["content"] == "hi"
finally:
agent_manager.sessions.pop(s.id, None)
seq_log.clear(s.id)
def test_replay_caught_up_client_gets_nothing_not_the_terminal_frame():
sid = "caughtup-session"
ws = p_FakeWs()
try:
asyncio.run(ws_manager.send_to_session(sid, "agent:status", {"session_id": sid, "status": "running"}))
asyncio.run(ws_manager.send_to_session(sid, "agent:status", {"session_id": sid, "status": "completed"}))
top = seq_log.current_seq(sid)
ack = asyncio.run(ws_manager.replay_to(sid, ws, top))
assert ack == {"ok": True, "replayed": 0, "current_seq": top}
assert ws.frames == []
# A behind client still gets the real replay.
ack2 = asyncio.run(ws_manager.replay_to(sid, ws, top - 1))
assert ack2["replayed"] == 1
finally:
seq_log.clear(sid)
def test_history_closed_only_filters_open_sessions(monkeypatch):
rows = [
("open1", {"id": "open1", "name": "open chat", "closed_at": None, "dashboard_id": None}),
("closed1", {"id": "closed1", "name": "closed chat", "closed_at": "2026-07-01T00:00:00", "dashboard_id": None}),
]
import backend.apps.agents.manager.session.SessionLifecycle as lifecycle_mod
monkeypatch.setattr(lifecycle_mod, "load_all_session_data", lambda: list(rows))
closed = agent_manager.get_history(closed_only=True)
assert [s["id"] for s in closed["sessions"]] == ["closed1"]
# Search keeps the full pool: open sessions on other dashboards are reachable nowhere else.
everything = agent_manager.get_history()
assert {s["id"] for s in everything["sessions"]} == {"open1", "closed1"}
def test_history_route_threads_closed_only(monkeypatch):
seen = {}
def p_spy(**kwargs):
seen.update(kwargs)
return {"sessions": [], "total": 0, "has_more": False}
monkeypatch.setattr(agent_manager, "get_history", p_spy)
assert p_client().get("/api/agents/history?closed_only=1").status_code == 200
assert seen["closed_only"] is True
assert p_client().get("/api/agents/history").status_code == 200
assert seen["closed_only"] is False
+35 -10
View File
@@ -1239,8 +1239,8 @@ function createWindow() {
});
if (isDev) {
// OPENSWARM_DEV_URL lets a worktree stack (webpack on an alternate port) run its own Electron.
mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:3000`);
// Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch.
mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`);
} else if (frontendServerPort) {
mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`);
} else {
@@ -1664,18 +1664,40 @@ function setupAutoUpdater() {
// the button uses. Deliberately conservative so it can never land on top of a live task.
const IDLE_INSTALL_MIN_IDLE_S = 30 * 60;
const IDLE_INSTALL_MIN_UPTIME_MS = 2 * 60 * 60 * 1000;
const IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S = 15 * 60;
const _idleInstallStart = Date.now();
const _backendActiveAgents = () => new Promise((resolve) => {
if (!backendPort) return resolve(-1);
const _backendActivity = () => new Promise((resolve) => {
if (!backendPort) return resolve(null);
const req = http.request({
hostname: '127.0.0.1', port: backendPort, path: '/api/agents/activity', method: 'GET',
headers: { ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 4000,
}, (res) => {
let d = ''; res.on('data', (c) => (d += c));
res.on('end', () => { try { resolve(Number(JSON.parse(d).active)); } catch (_) { resolve(-1); } });
res.on('end', () => {
try {
const j = JSON.parse(d);
resolve({ active: Number(j.active), nextRunInS: j.next_run_in_s == null ? null : Number(j.next_run_in_s) });
} catch (_) { resolve(null); }
});
});
req.on('error', () => resolve(-1));
req.on('timeout', () => { req.destroy(); resolve(-1); });
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
// Breadcrumb so fleet convergence is queryable in analytics; bounded + best-effort, the install never waits on it failing.
const _reportIdleInstall = () => new Promise((resolve) => {
if (!backendPort) return resolve();
const payload = JSON.stringify({
kind: 'idle_install',
staged_version: (cachedUpdateStatus && cachedUpdateStatus.info && cachedUpdateStatus.info.version) || null,
});
const req = http.request({
hostname: '127.0.0.1', port: backendPort, path: '/api/service/updater-event', method: 'POST',
headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 2000,
}, (res) => { res.resume(); res.on('end', resolve); });
req.on('error', resolve);
req.on('timeout', () => { req.destroy(); resolve(); });
req.write(payload);
req.end();
});
setInterval(async () => {
@@ -1683,9 +1705,12 @@ function setupAutoUpdater() {
if (isInstallingUpdate || !cachedUpdateStatus || cachedUpdateStatus.status !== 'downloaded') return;
if (Date.now() - _idleInstallStart < IDLE_INSTALL_MIN_UPTIME_MS) return;
if (powerMonitor.getSystemIdleTime() < IDLE_INSTALL_MIN_IDLE_S) return;
const active = await _backendActiveAgents();
if (active !== 0) return; // unknown (-1) or busy -> stay put, never interrupt a task
console.log('[updater] staged update + machine idle + no agents; applying silently');
const act = await _backendActivity();
if (!act || act.active !== 0) return; // unknown or busy -> stay put, never interrupt a task
// A scheduled workflow fires soon; restarting now would race it. Let it run, catch the next idle window.
if (act.nextRunInS != null && act.nextRunInS < IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S) return;
console.log('[updater] staged update + machine idle + no agents + no imminent workflow; applying silently');
try { await _reportIdleInstall(); } catch (_) {}
installDownloadedUpdate();
} catch (_) { /* a heartbeat must never throw */ }
}, 5 * 60 * 1000);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.5.7",
"version": "1.5.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.5.7",
"version": "1.5.8",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "6.8.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.5.7",
"version": "1.5.8",
"description": "OpenSwarm — AI Agent Orchestrator",
"author": "openswarm-ai",
"main": "main.js",
+1 -3
View File
@@ -225,11 +225,9 @@ try {
} catch (_) {}
return;
}
// A browser page or app owns its OWN scroll/zoom (Google Maps zoom, Figma pan, page scroll), so plain wheel stays with the guest. Only horizontal-dominant swipes with nothing to scroll horizontally fall through to a canvas pan.
if (isInteractive) return;
// Vertical-dominant scroll stays with the page.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Horizontal-dominant: defer to the page if anything inside can absorb
// it; otherwise forward to the host as a canvas pan.
if (pageCanScrollX(e.target, e.deltaX)) return;
e.preventDefault();
e.stopPropagation();
@@ -272,7 +272,9 @@ const AppShell: React.FC = () => {
dispatch(setPendingBrowserUrl(url));
const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined;
const firstDashboard = dashboardList[0];
const targetId = lastId || firstDashboard?.id;
// Only navigate to lastId if it's a REAL dashboard: a stale localStorage id for a deleted dashboard used to route to /dashboard/<phantom>, which 404s and re-fires the layout wipe (drops your cards / breaks a drag).
const lastIsReal = !!lastId && dashboardList.some((d) => d.id === lastId);
const targetId = (lastIsReal ? lastId : undefined) || firstDashboard?.id;
if (targetId) {
navigate(`/dashboard/${targetId}`);
} else {
@@ -9,6 +9,8 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useNavigate } from 'react-router-dom';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import ImportDigest, { DigestHandle } from './ImportDigest';
import ImportModal from './ImportModal';
@@ -43,6 +45,8 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
const inputRef = useRef<HTMLInputElement | null>(null);
const digestRef = useRef<DigestHandle | null>(null);
const depth = useRef(0);
@@ -56,10 +60,12 @@ const ImportEntryPoint: React.FC = () => {
(rootType: string, rootId: string, name: string) => {
const msg = rootType === 'app' ? `Added ${name} to your Apps` : `Added ${name}`;
setToast({ msg, sev: 'success' });
// A workflow has no route of its own, so nothing would pull it in: an open Workflows hub only fetches on mount and would keep showing a stale list. Import drops dashboard_id, and /list keeps unassigned workflows for every dashboard, so this surfaces it wherever the user is.
if (rootType === 'workflow') dispatch(fetchWorkflows(dashboardId));
const to = DEST[rootType]?.(rootId);
if (to) navigate(to);
},
[navigate],
[navigate, dispatch, dashboardId],
);
const commitAndFinish = useCallback(
@@ -55,11 +55,11 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
return load();
}, [open, load]);
const handleDownload = async () => {
const handleDownload = async (allowSecrets = false) => {
if (!preflight) return;
setDownloading(true);
try {
await downloadSwarm(target, preflight.filename);
await downloadSwarm(target, preflight.filename, allowSecrets);
setToast(`Saved ${preflight.filename}`);
onClose();
} catch (e: any) {
@@ -68,6 +68,8 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
setDownloading(false);
}
};
// The file-content secret heuristic is overridable (download goes to people you trust); our own credential fields ("secret-shaped field(s)") are not.
const secretOverridable = error.includes('secret-shaped value');
const optionRow = (
selected: boolean,
@@ -150,6 +152,16 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
Try again
</Button>
{secretOverridable && (
<Button
size="small"
onClick={() => { setError(''); handleDownload(true); }}
disabled={downloading}
sx={{ textTransform: 'none', color: c.status.error, ml: 1 }}
>
Export anyway (includes the flagged value; only send to people you trust)
</Button>
)}
</Box>
) : preflight ? (
<IncludesList summary={preflight.summary} />
@@ -179,7 +191,7 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
onClick={handleDownload}
onClick={() => handleDownload()}
disabled={!preflight || downloading}
startIcon={
downloading ? (
@@ -28,11 +28,11 @@ export async function exportPreflight(target: ShareTarget): Promise<ExportPrefli
return res.json();
}
export async function downloadSwarm(target: ShareTarget, filename: string): Promise<void> {
export async function downloadSwarm(target: ShareTarget, filename: string, allowSecrets = false): Promise<void> {
const res = await fetch(`${API_BASE}/swarm/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: target.kind, id: target.id }),
body: JSON.stringify({ type: target.kind, id: target.id, allow_secrets: allowSecrets }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't build the file."));
const blob = await res.blob();
@@ -47,7 +47,7 @@ import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDispla
import { Typewriter } from '@/app/components/feedback/Animated';
import { store } from '@/shared/state/store';
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager';
import { createSessionWs, acquireSessionWs, releaseSessionWs, seedSessionSeq } from '@/shared/ws/WebSocketManager';
import StreamingBubble from './bubbles/StreamingBubble';
import WelcomeQuickReplies from './WelcomeQuickReplies';
import { useWelcomeGreeting } from './useWelcomeGreeting';
@@ -376,7 +376,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
dispatch(fetchSession(id));
} else {
try {
await dispatch(fetchSession(id));
const action = await dispatch(fetchSession(id));
// Seed the resume cursor from the snapshot's seq so the connect below doesn't replay the whole ring buffer we just hydrated over REST.
if (fetchSession.fulfilled.match(action)) {
const seq = (action.payload as { event_seq?: number }).event_seq;
if (typeof seq === 'number') seedSessionSeq(id, seq);
}
} catch {
// Even if the REST hydrate fails, still connect, the WS resume protocol can hydrate from buffered events as a fallback.
}
@@ -92,7 +92,7 @@ function modelFamilyKey(label: string): string {
.trim();
}
/** Sort: intelligence desc, family asc, version desc, label asc. */
/** Sort: intelligence desc, version desc (newest first within a tier), family asc, label asc. */
export 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];
@@ -102,12 +102,13 @@ export function sortModelsForPicker<T extends { label: string }>(models: T[]): T
const intelA = intelOf(a);
const intelB = intelOf(b);
if (intelA !== intelB) return intelB - intelA;
const famA = modelFamilyKey(a.label);
const famB = modelFamilyKey(b.label);
if (famA !== famB) return famA.localeCompare(famB);
// Version before family: among models of similar capability, the NEWEST goes on top (Sonnet 5 above Opus 4.6, not buried under the alphabetically-earlier "opus" family).
const verA = modelVersion(a.label);
const verB = modelVersion(b.label);
if (verA !== verB) return verB - verA;
const famA = modelFamilyKey(a.label);
const famB = modelFamilyKey(b.label);
if (famA !== famB) return famA.localeCompare(famB);
return a.label.localeCompare(b.label);
});
}
@@ -13,7 +13,9 @@ export function isInvokeAgentTool(name: string): boolean {
}
export function isCreateAgentTool(name: string): boolean {
return name === 'Agent';
if (name === 'Agent') return true;
const mcp = parseMcpToolName(name);
return mcp.isMcp && mcp.serverSlug === 'openswarm-spawn-agent';
}
export function parseInvokedSessionId(rawText: string): string | null {
@@ -155,7 +155,8 @@ const lightFeedColors: FeedColors = {
// 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(
// Factory, one selector PER FEED: a module-level createSelector has a cache of 1 shared by every mounted feed, so two feeds with different args thrash it and every render recomputes (and returns a fresh array identity, which defeats all downstream memoization).
const makeSelectBrowserSessions = () => createSelector(
[(state: RootState) => state.agents.sessions,
(_: RootState, parentSessionId: string) => parentSessionId,
(_: RootState, __: string, browserId?: string) => browserId],
@@ -166,6 +167,8 @@ const selectBrowserSessions = createSelector(
s.parent_session_id === parentSessionId &&
(!browserId || s.browser_id === browserId),
),
// Same members = same array identity: ANY session update rebuilds the sessions dict, and without this every unrelated agent:status re-ran formatMessage over the whole feed history.
{ memoizeOptions: { resultEqualityCheck: shallowEqual } },
);
const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId }) => {
@@ -176,6 +179,7 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(null);
const selectBrowserSessions = useMemo(makeSelectBrowserSessions, []);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
@@ -197,29 +201,37 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
shallowEqual,
);
// A child that arrived only through the trimmed session-list poll carries its message_count but no messages; fetch the full children so its history renders instead of showing a blank feed. Keyed by the unhydrated-children set (not one-shot per parent) so a NEW child appearing mid-run still hydrates, while the same set never refetches (no loop).
const unhydratedKey = browserSessions.length === 0
? `${parentSessionId}:empty`
: browserSessions.filter((s) => (s.message_count ?? 0) > 0 && s.messages.length === 0).map((s) => s.id).sort().join(',');
useEffect(() => {
if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
fetchedForSession.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
if (!unhydratedKey.endsWith(':empty') && unhydratedKey === '') return;
if (fetchedForSession.current === unhydratedKey) return;
fetchedForSession.current = unhydratedKey;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}, [unhydratedKey, parentSessionId, dispatch]);
const sessionsWithEntries = useMemo(() => {
const sessionsWithHistoricalEntries = useMemo(() => {
return browserSessions.map((session) => {
const entries: FeedEntry[] = [];
for (const msg of session.messages) {
const entry = formatMessage(msg);
if (entry) entries.push(entry);
}
const stream: StreamingMessage | undefined = streamingBySession[session.id];
if (stream?.role === 'assistant' && stream.content) {
entries.push({ type: 'thought', text: stream.content });
}
return { session, entries };
});
}, [browserSessions, streamingBySession]);
}, [browserSessions]);
const sessionsWithEntries = sessionsWithHistoricalEntries.map(({ session, entries }) => {
const stream: StreamingMessage | undefined = streamingBySession[session.id];
if (stream?.role === 'assistant' && stream.content) {
return { session, entries: [...entries, { type: 'thought' as const, text: stream.content }] };
}
return { session, entries };
});
const totalMessages = browserSessions.reduce(
(n, s) => n + s.messages.length + (streamingBySession[s.id] ? 1 : 0),
@@ -404,7 +416,8 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
);
};
const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
// Memoized: the feed re-renders on every streamed token, and un-memoized rows re-render the ENTIRE lazy-loaded history per token (the "browser use = hella lag" bug).
const EntryRow = React.memo<{ entry: FeedEntry; accentColor: string; fc: FeedColors }>(({ entry, accentColor, fc }) => {
const c = useClaudeTokens();
if (entry.type === 'thought') {
@@ -485,7 +498,7 @@ const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors
}
return null;
};
});
const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
@@ -75,7 +75,7 @@ interface DashboardCanvasProps {
onViewportMouseMove: (e: React.MouseEvent) => void;
onViewportMouseUp: (e: React.MouseEvent) => void;
onViewportDoubleClick: (e: React.MouseEvent) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
@@ -185,6 +185,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
return () => window.removeEventListener('keydown', onKey, true);
}, [fullscreenCardId, dispatch]);
// Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove.
React.useLayoutEffect(() => {
canvas.actions.syncTransform();
});
return (
<>
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
@@ -311,8 +316,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
)}
{/* Dot grid background */}
{/* Dot grid background; gestures move it imperatively via gridRef (phase + scale), commits re-render it here (dot radius included) */}
<Box
ref={canvas.gridRef}
sx={{
position: 'absolute',
inset: 0,
@@ -348,9 +354,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
outputs={outputs}
glowingAgentCards={glowingAgentCards}
expandedSessionIds={expandedSessionIds}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
cmdHeld={canvas.cmdHeld}
selection={selection}
highlightedCardId={highlightedCardId}
@@ -39,9 +39,6 @@ interface DashboardCardLayerProps {
outputs: Record<string, Output>;
glowingAgentCards: Record<string, GlowingAgentCard>;
expandedSessionIds: string[];
zoom: number;
panX: number;
panY: number;
cmdHeld: boolean;
selection: Selection;
highlightedCardId: string | null;
@@ -54,7 +51,7 @@ interface DashboardCardLayerProps {
revealSpawnedRef: RefObject<Set<string>>;
measuredHeightsRef: RefObject<Record<string, number>>;
getCanvasState: () => { panX: number; panY: number; zoom: number };
onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
@@ -76,9 +73,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
outputs,
glowingAgentCards,
expandedSessionIds,
zoom,
panX,
panY,
cmdHeld,
selection,
highlightedCardId,
@@ -205,9 +199,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={vc.width}
cardHeight={vc.height}
cardZOrder={vc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
isSelected={selection.isSelected(cardKey)}
isHighlighted={highlightedCardId === cardKey}
@@ -234,9 +226,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={bc.width}
cardHeight={bc.height}
cardZOrder={bc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
isSelected={selection.isSelected(bc.browser_id)}
isHighlighted={highlightedCardId === bc.browser_id}
@@ -258,9 +248,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={n.width}
cardHeight={n.height}
cardZOrder={n.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
content={n.content}
color={n.color}
@@ -282,9 +270,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
isSelected={selection.isSelected('workflows-hub')}
isHighlighted={highlightedCardId === 'workflows-hub'}
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
@@ -303,9 +289,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={monitorCard.width}
cardHeight={monitorCard.height}
cardZOrder={monitorCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
@@ -21,7 +21,8 @@ const TetherLayer: React.FC<TetherLayerProps> = ({ tethers, c }) => {
height: 1,
overflow: 'visible',
pointerEvents: 'none',
zIndex: 10,
// Behind every card (cards use zOrder 1..N as their z-index): connector lines tuck UNDER the cards like a node graph, visible only in the gaps between them. At zIndex 10 the line drew OVER any card with zOrder < 10, so it cut through the chat and the browsers.
zIndex: 0,
}}
>
<defs>
@@ -344,15 +344,20 @@ const AgentCard: React.FC<Props> = ({
return Boolean(sourceWorkflow);
}, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]);
const hasUserPrompt = useMemo(
() => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
[session.messages],
() => session.messages.length > 0
? session.messages.some((m) => m.role === 'user' && !m.hidden)
: !!session.first_user_message,
[session.messages, session.first_user_message],
);
const messageCount = session.messages.length > 0
? session.messages.length
: session.message_count ?? 0;
const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
const showConvertToWorkflow =
!session.is_welcome_draft &&
!isWorkflowRunnerSession &&
hasUserPrompt &&
(session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
(messageCount >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn;
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
@@ -368,7 +373,7 @@ const AgentCard: React.FC<Props> = ({
if (s.includes('/')) s = s.split('/').pop() || s;
return s;
}, [session.model, modelsByProvider]);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected && !expanded);
const suggestionPulseRef = useRef('');
const readyPulseRef = useRef('');
@@ -679,7 +684,7 @@ const AgentCard: React.FC<Props> = ({
).slice(0, 120)
: lastMessage && typeof lastMessage.content === 'string'
? lastMessage.content.slice(0, 120)
: '';
: session.last_message_preview ?? '';
const hasPending = session.pending_approvals.length > 0;
const pendingReq = session.pending_approvals[0];
@@ -889,8 +894,8 @@ const AgentCard: React.FC<Props> = ({
/>
))}
{/* Selection overlay , blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
{/* Selection overlay , drag-from-anywhere for a COLLAPSED selected card. Never over an expanded chat: it would sit on the composer/transcript so you couldn't type or click (that was "chat opens stuck in drag mode"). Expanded chats drag via the header zone below (zIndex 16). */}
{isSelected && !expanded && (
<Box
ref={scrollOverlayRef}
onPointerDown={handleDragPointerDown}
@@ -50,6 +50,8 @@ import {
registerWebview,
unregisterWebview,
setActiveTab as setRegistryActiveTab,
registerPendingLoad,
wakePendingLoad,
type BrowserWebview,
} from '@/shared/browserRegistry';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
@@ -179,16 +181,14 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
// Belongs to a non-active dashboard but kept mounted-hidden so its webContents + sessionStorage survive the switch.
keepAliveHidden?: boolean;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
@@ -199,7 +199,7 @@ interface Props {
const BrowserCard: React.FC<Props> = ({
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
@@ -290,8 +290,14 @@ const BrowserCard: React.FC<Props> = ({
}
}, []);
// Kept current so the mount-time load decision (eager vs deferred) reads the live active tab, not a stale closure (the load effect keys on the tab SET, not activeTabId).
const activeTabIdRef = useRef(activeTabId);
useEffect(() => {
activeTabIdRef.current = activeTabId;
setRegistryActiveTab(browserId, activeTabId);
// Switching to a deferred background tab loads it now; no-op if it already loaded or hasn't reached dom-ready yet (onReady then loads it eagerly because it's the active tab).
const wv = webviewMap.current.get(activeTabId);
if (wv) wakePendingLoad(wv);
}, [browserId, activeTabId]);
// Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input.
@@ -345,8 +351,15 @@ const BrowserCard: React.FC<Props> = ({
(wv as any).setZoomFactor?.(1);
} catch (_) {}
};
wv.addEventListener('dom-ready', doLoad, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', doLoad));
// Lazy tabs: only the VISIBLE tab loads its page on mount. A background tab stays at
// about:blank (deferred) so a many-tab card doesn't load every page at once; it's woken
// the instant it becomes active OR an agent command resolves it (browserRegistry wake).
const onReady = () => {
if (tabId === activeTabIdRef.current) doLoad();
else registerPendingLoad(wv, targetUrl, doLoad);
};
wv.addEventListener('dom-ready', onReady, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', onReady));
}
const mirrorUrl = () => dispatch(updateBrowserTabUrl({ browserId, tabId, url: wv.getURL() }));
@@ -427,6 +440,13 @@ const BrowserCard: React.FC<Props> = ({
});
};
// A failed/aborted main-frame load never fires did-stop-loading, and initializedTabs is already set so doLoad won't re-arm: without this the card sits blank with the spinner running forever. errorCode -3 is ERR_ABORTED (a superseded nav), not a failure.
const onDidFailLoad = (e: any) => {
if (!e || e.isMainFrame === false) return;
updateTabLocal(tabId, { loading: false });
if (e.errorCode && e.errorCode !== -3) onProcessGone();
};
const onFaviconUpdate = (e: any) => {
const favicons = e.favicons || (e.detail && e.detail.favicons);
if (favicons?.[0]) {
@@ -451,6 +471,7 @@ const BrowserCard: React.FC<Props> = ({
wv.addEventListener('new-window', onNewWindow as any);
wv.addEventListener('render-process-gone', onProcessGone as any);
wv.addEventListener('crashed', onProcessGone as any);
wv.addEventListener('did-fail-load', onDidFailLoad as any);
cleanups.push(() => {
unregisterWebview(browserId, tabId);
@@ -464,6 +485,7 @@ const BrowserCard: React.FC<Props> = ({
wv.removeEventListener('new-window', onNewWindow as any);
wv.removeEventListener('render-process-gone', onProcessGone as any);
wv.removeEventListener('crashed', onProcessGone as any);
wv.removeEventListener('did-fail-load', onDidFailLoad as any);
const churn = urlChurnThrottle.current.get(tabId);
if (churn?.timer) { clearTimeout(churn.timer); churn.timer = null; }
});
@@ -654,8 +676,9 @@ const BrowserCard: React.FC<Props> = ({
// Screen -> canvas: derive the transform origin from this card's own strip (screenX = originX + canvasX * zoom).
const barRect = tabBarRef.current?.getBoundingClientRect();
if (barRect) {
const dropX = (e.clientX - (barRect.left - cardX * zoomRef.current)) / zoomRef.current - 40;
const dropY = (e.clientY - (barRect.top - cardY * zoomRef.current)) / zoomRef.current - 16;
const z = getCanvasState().zoom;
const dropX = (e.clientX - (barRect.left - cardX * z)) / z - 40;
const dropY = (e.clientY - (barRect.top - cardY * z)) / z - 16;
dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, x: dropX, y: dropY }));
}
}
@@ -665,7 +688,7 @@ const BrowserCard: React.FC<Props> = ({
setDragTabOffset(0);
setDetachGhost(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [handleSwitchTab, dispatch, browserId, cardX, cardY]);
}, [handleSwitchTab, dispatch, browserId, cardX, cardY, getCanvasState]);
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
@@ -675,22 +698,18 @@ const BrowserCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId]);
}, [cardX, cardY, onDragStart, browserId, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -698,18 +717,25 @@ const BrowserCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -723,9 +749,10 @@ const BrowserCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -750,7 +777,7 @@ const BrowserCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, browserId, onDragEnd]);
}, [dispatch, browserId, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -778,6 +805,7 @@ const BrowserCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -789,7 +817,7 @@ const BrowserCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -820,6 +848,10 @@ const BrowserCard: React.FC<Props> = ({
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
// During a drag, move the card by a COMPOSITOR transform, not left/top layout: while edge-panning, the canvas transform and the card's left/top update land a frame apart, and the webview's guest surface follows the transform immediately while left/top relayouts late, so the browser visibly shimmers back and forth. A transform for the drag delta rides the same compositor path as the canvas pan, so they move together in one frame.
const dragging = isDragging && !!localDragPos && !localResize;
const dragTx = dragging ? displayX - cardX : 0;
const dragTy = dragging ? displayY - cardY : 0;
const isSecure = activeUrl.startsWith('https://');
const isSearch = isGoogleSearch(activeUrl);
@@ -866,8 +898,8 @@ const BrowserCard: React.FC<Props> = ({
data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(browserId, 'browser');
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false);
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false, e.target);
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
@@ -885,8 +917,9 @@ const BrowserCard: React.FC<Props> = ({
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale.
willChange: 'transform',
left: keepAliveHidden || isMinimized ? -100000 : displayX,
top: displayY,
left: keepAliveHidden || isMinimized ? -100000 : (dragging ? cardX : displayX),
top: dragging ? cardY : displayY,
transform: dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined,
width: displayW,
height: displayH,
borderRadius: `${c.radius.lg}px`,
@@ -73,9 +73,7 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
@@ -125,7 +123,7 @@ const BootingBody: React.FC = () => {
};
const DashboardViewCard: React.FC<Props> = ({
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
@@ -135,10 +133,23 @@ const DashboardViewCard: React.FC<Props> = ({
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId);
// Agent-driving glow, same treatment as browser cards: an AppAgent session carries browser_id "app:<output_id>", which keys glowingBrowserCards.
const appGlow = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards[`app:${cardKeyProp ?? output.id}`]);
const showAgentGlow = !!appGlow && !appGlow.fading;
const interactive = activeViewCardId === cardKey;
const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]);
const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]);
const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom);
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
const [tileTick, setTileTick] = useState(0);
useEffect(() => {
if (!tileZone) return undefined;
const onPan = (): void => setTileTick((t) => t + 1);
window.addEventListener('openswarm:canvas-pan-changed', onPan);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const isFullscreen = tileZone === 'fullscreen';
// Deselecting the card exits interact mode (click anywhere else on canvas).
@@ -228,22 +239,19 @@ const DashboardViewCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(cardKey, 'view');
}, [cardX, cardY, onDragStart, cardKey]);
}, [cardX, cardY, onDragStart, cardKey, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -251,18 +259,25 @@ const DashboardViewCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -276,9 +291,10 @@ const DashboardViewCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -303,7 +319,7 @@ const DashboardViewCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, cardKey, onDragEnd]);
}, [dispatch, cardKey, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -331,6 +347,7 @@ const DashboardViewCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -342,7 +359,7 @@ const DashboardViewCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -419,6 +436,10 @@ const DashboardViewCard: React.FC<Props> = ({
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
// Drag via a compositor transform, not left/top: an app card's webview surface shimmers back and forth while edge-panning otherwise (the transform and the late left/top relayout desync a frame). Same fix as BrowserCard.
const dragging = isDragging && !!localDragPos && !localResize;
const dragTx = dragging ? displayX - cardX : 0;
const dragTy = dragging ? displayY - cardY : 0;
return (
<Box
@@ -440,31 +461,35 @@ const DashboardViewCard: React.FC<Props> = ({
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
contain: 'layout style',
willChange: 'transform',
left: tiledStyle ? tiledStyle.left : displayX,
top: tiledStyle ? tiledStyle.top : displayY,
left: tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX),
top: tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY),
width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW),
height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH),
transform: tiledStyle ? tiledStyle.transform : undefined,
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`,
border: isHighlighted
? `2px solid ${c.accent.primary}`
: interactive
: showAgentGlow
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
: interactive
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
boxShadow: isHighlighted
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md,
: showAgentGlow
? `0 0 0 2px ${c.accent.primary}40, 0 0 18px ${c.accent.primary}30, 0 0 40px ${c.accent.primary}15, inset 0 0 30px ${c.accent.primary}25`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.2s',
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
animation: 'card-highlight-pulse 2s ease-out forwards',
@@ -60,9 +60,7 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
@@ -71,7 +69,7 @@ interface Props {
color: NoteColor;
cardZOrder?: number;
autoFocus?: boolean;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean) => void;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
@@ -79,7 +77,7 @@ interface Props {
}
const NoteCard: React.FC<Props> = ({
noteId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0,
noteId, cardX, cardY, cardWidth, cardHeight, getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta, content, color,
cardZOrder = 0, autoFocus, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
@@ -96,10 +94,6 @@ const NoteCard: React.FC<Props> = ({
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const [showColorPicker, setShowColorPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -116,17 +110,18 @@ const NoteCard: React.FC<Props> = ({
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
startPanX: cs.panX, startPanY: cs.panY,
};
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(noteId, 'note');
}, [cardX, cardY, noteId, onDragStart]);
}, [cardX, cardY, noteId, onDragStart, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -134,18 +129,25 @@ const NoteCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -159,9 +161,10 @@ const NoteCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -181,7 +184,7 @@ const NoteCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, noteId, onDragEnd]);
}, [dispatch, noteId, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -209,6 +212,7 @@ const NoteCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -220,7 +224,7 @@ const NoteCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -255,7 +259,17 @@ const NoteCard: React.FC<Props> = ({
if (zone === 'restore') dispatch(clearTiledCard(noteId));
else dispatch(setTiledCard({ cardId: noteId, zone }));
};
const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom);
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
const [tileTick, setTileTick] = useState(0);
useEffect(() => {
if (!tileZone) return undefined;
const onPan = (): void => setTileTick((t) => t + 1);
window.addEventListener('openswarm:canvas-pan-changed', onPan);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const isFullscreen = tileZone === 'fullscreen';
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
@@ -273,8 +287,8 @@ const NoteCard: React.FC<Props> = ({
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(noteId, 'note');
// Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false);
// Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. Pass the target so a textarea press selects without yanking the camera.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false, e.target);
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
@@ -258,9 +258,11 @@ export function useTethers({
}
const glowTethers = new Map<string, ReturnType<typeof cardTether>>();
// An "app:<output_id>" glow key targets a VIEW card (AppAgent driving an app); everything else is a browser card.
const glowTarget = (id: string) => (id.startsWith('app:') ? viewCards[id.slice(4)] : browserCards[id]);
for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) {
const t = cardTether(
browserCards[browserId],
glowTarget(browserId),
browserId,
sourceId,
`browser-${browserId}`,
@@ -278,7 +280,7 @@ export function useTethers({
// A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance; the right-docked agent/run cases stay label-free (their glow already said it on spawn).
const parent = sessionById.get(s.parent_session_id);
const t = cardTether(
browserCards[s.browser_id],
glowTarget(s.browser_id),
s.browser_id,
s.parent_session_id,
`browser-${s.browser_id}`,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { scrollCardContentX } from '@/shared/cardContentScroll';
import { useAppDispatch } from '@/shared/hooks';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
@@ -108,6 +109,8 @@ export function useArrowNav({
focusedCardIdRef.current = focusedCardId;
const canvasZoomRef = useRef(zoom);
canvasZoomRef.current = zoom;
// Set while we're waiting to hear whether the focused card's content absorbed a Left/Right; see the handler for why a held key must not stack these.
const scrollProbeRef = useRef(false);
useEffect(() => {
// Helper: is the currently-focused element a text-entry field the user is actively editing? We only want to suppress dashboard navigation when the user is genuinely typing, not just because an input somewhere happens to have focus from a click long ago.
@@ -124,6 +127,35 @@ export function useArrowNav({
return true;
};
const navigateToNeighbor = (fromCardId: string, direction: Direction) => {
const target = findNearestCard(fromCardId, direction);
if (!target) {
// No card in that direction, shake
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setShakeDirection(direction);
shakeTimerRef.current = setTimeout(() => {
setShakeDirection(null);
shakeTimerRef.current = null;
}, 400);
return;
}
// Expand + navigate to target + bring to front
report('dashboard', 'arrow_navigated', { direction, from_card: fromCardId, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
dispatch(bringToFront({ id: target.id, type: target.type }));
setFocusedCardId(target.id);
setTimeout(() => {
const rect = getCardRect(target.id, target.type);
if (rect) canvasActions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
};
const handleKey = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
@@ -162,32 +194,22 @@ export function useArrowNav({
}
e.preventDefault();
const target = findNearestCard(currentFocused, direction);
if (!target) {
// No card in that direction, shake
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setShakeDirection(direction);
shakeTimerRef.current = setTimeout(() => {
setShakeDirection(null);
shakeTimerRef.current = null;
}, 400);
// Left/Right belong to the focused card's own content first: while it can still scroll that way it eats the key, and only once it's at its horizontal boundary (or has nothing to scroll sideways) does the arrow go back to meaning card-to-card navigation. Same hand-off the wheel already does in useCanvasControls, so a Sheets card behaves the same under the trackpad and under the keyboard. Up/Down are untouched: most cards scroll vertically, so applying this rule to them would quietly take away vertical nav across the whole canvas.
const fromCardId = currentFocused;
if (direction === 'left' || direction === 'right') {
// A webview card's content lives in another renderer, so the answer can't arrive before this handler returns. Drop repeats while a probe is in flight instead of stacking round-trips: a held key would otherwise queue several, and the ones that land after the card hits its boundary would all navigate.
if (scrollProbeRef.current) return;
scrollProbeRef.current = true;
scrollCardContentX(fromCardId, direction)
.then((scrolled) => {
if (!scrolled) navigateToNeighbor(fromCardId, direction);
})
.finally(() => { scrollProbeRef.current = false; });
return;
}
// Expand + navigate to target + bring to front
report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
dispatch(bringToFront({ id: target.id, type: target.type }));
setFocusedCardId(target.id);
setTimeout(() => {
const rect = getCardRect(target.id, target.type);
if (rect) canvasActions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
navigateToNeighbor(fromCardId, direction);
};
// Capture phase so we beat MUI Menus/Selects that also listen for arrows. We still bail early on isActivelyEditing, so this doesn't interfere with typing.
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
import { setCanvasInteractionActive } from '@/shared/canvasInteractionState';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
@@ -9,6 +10,12 @@ const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
// Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide.
const FIT_DURATION = 150;
// Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight.
const FIT_SETTLE_DELAY = FIT_DURATION + 60;
// A mouse notch lands as deltaY 100 where a trackpad sends ~1-10, so cap the per-event zoom delta: uncapped, one notch is a ~24% jump and macOS wheel acceleration stacks them. No-op for trackpads.
const WHEEL_ZOOM_DELTA_CAP = 24;
// Maps the 1 to 100 user setting to an internal multiplier (50 default = 0.004).
function sensitivityToMultiplier(setting: number): number {
@@ -35,6 +42,7 @@ export interface ContentBounds {
export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds, enabled: boolean = true) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
const [isPanning, setIsPanning] = useState(false);
@@ -42,8 +50,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const [cmdHeld, setCmdHeld] = useState(false);
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
// stateRef is the LIVE camera truth (single writer: applyLive / setCanvasState below). React state is a lagging copy committed once per gesture-end, so a 120Hz pan doesn't re-render the card tree per frame. Never sync stateRef FROM state: a render mid-gesture would clobber live with stale.
const stateRef = useRef(state);
stateRef.current = state;
const liveDirtyRef = useRef(false);
const spaceRef = useRef(false);
const cmdRef = useRef(false);
const sensitivityRef = useRef(zoomSensitivity);
@@ -59,6 +68,44 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const FRICTION = 0.93;
const MIN_VELOCITY = 0.5;
// Paints stateRef onto the DOM: content transform (compositor-only) + dot-grid phase/scale. Also the after-render re-apply, so a foreign React render mid-gesture can't paint the stale committed transform for a frame.
const applyLiveToDom = useCallback(() => {
const { panX, panY, zoom } = stateRef.current;
const content = contentRef.current;
if (content) content.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
const grid = gridRef.current;
if (grid) {
const spacing = 24 * zoom;
grid.style.backgroundPosition = `${panX % spacing}px ${panY % spacing}px`;
// Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes.
grid.style.backgroundSize = `${spacing}px ${spacing}px`;
}
}, []);
// Per-frame camera write during a gesture: DOM + live ref only, NO React commit. Dragging cards re-pin to the cursor off the pan-changed event, same signal the old per-frame commit produced.
const applyLive = useCallback((next: CanvasState) => {
stateRef.current = next;
liveDirtyRef.current = true;
applyLiveToDom();
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
}, [applyLiveToDom]);
// Gesture-end: reconcile React (minimap, zoom label, webview suspend) with the live camera in ONE render.
const commitLive = useCallback(() => {
if (!liveDirtyRef.current) return;
liveDirtyRef.current = false;
setState(stateRef.current);
}, []);
// Discrete camera set (minimap jump, fit fallbacks): live + committed in the same call. The ONLY sanctioned writers are this and applyLive; a new pan path calling raw setState reintroduces the camera-snaps-back class.
const setCanvasState = useCallback((updater: CanvasState | ((prev: CanvasState) => CanvasState)) => {
const next = typeof updater === 'function' ? updater(stateRef.current) : updater;
stateRef.current = next;
liveDirtyRef.current = false;
applyLiveToDom();
setState(next);
}, [applyLiveToDom]);
const cancelInertia = useCallback(() => {
if (inertiaFrameRef.current) {
cancelAnimationFrame(inertiaFrameRef.current);
@@ -77,20 +124,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) {
inertiaFrameRef.current = null;
commitLive();
springBackIfNeeded();
return;
}
setState((prev) => ({
...prev,
panX: prev.panX + velocityX,
panY: prev.panY + velocityY,
}));
const prev = stateRef.current;
applyLive({ ...prev, panX: prev.panX + velocityX, panY: prev.panY + velocityY });
inertiaFrameRef.current = requestAnimationFrame(step);
};
inertiaFrameRef.current = requestAnimationFrame(step);
}, [cancelInertia]);
}, [cancelInertia, applyLive, commitLive]);
// ---- Soft pan boundaries: spring back if viewport drifts too far from content ----
const BOUNDARY_MARGIN = 800; // extra px beyond content bounds before spring-back
@@ -157,7 +202,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
setState({
applyLive({
panX: start.panX + (target.panX - start.panX) * ease,
panY: start.panY + (target.panY - start.panY) * ease,
zoom: start.zoom + (target.zoom - start.zoom) * ease,
@@ -166,14 +211,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
animFrameRef.current = requestAnimationFrame(step);
} else {
animFrameRef.current = null;
commitLive();
}
};
animFrameRef.current = requestAnimationFrame(step);
}, [cancelAnimation]);
}, [cancelAnimation, applyLive, commitLive]);
animateToRef.current = animateTo;
// Wheel zoom centered on cursor
// Plain wheel zooms at the viewport center; cmd/ctrl+wheel pans vertically; trackpad pinch zooms at the cursor.
useEffect(() => {
const el = viewportRef.current;
if (!el || !enabled) return; // Skip wheel listener when canvas is hidden
@@ -194,23 +240,19 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
pendingPanDx = 0; pendingPanDy = 0;
pendingZoomDy = 0; pendingZoomCenter = null;
const prev = stateRef.current;
if (zCenter && zDy !== 0) {
setState((prev) => {
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = newZoom / prev.zoom;
return {
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio,
zoom: newZoom,
};
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = newZoom / prev.zoom;
// Apply any pan accumulated in the same frame too: a zoom and a pan can now land together (vertical zoom + horizontal pan across a RAF boundary, or a forwarded pan), and dropping it would swallow the gesture.
applyLive({
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy,
zoom: newZoom,
});
} else if (dx !== 0 || dy !== 0) {
setState((prev) => ({
...prev,
panX: prev.panX - dx,
panY: prev.panY - dy,
}));
applyLive({ ...prev, panX: prev.panX - dx, panY: prev.panY - dy });
}
};
@@ -221,6 +263,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
wheelIdleTimer = setTimeout(() => {
wheelIdleTimer = null;
setCanvasInteractionActive(false);
commitLive();
}, 140);
if (wheelRafId != null) return;
wheelRafId = requestAnimationFrame(flushWheel);
@@ -230,8 +273,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
const onWheel = (e: WheelEvent) => {
// Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not
const isPinchZoom = e.ctrlKey || e.metaKey;
// ctrl/cmd wheel is a modifier gesture: a real held key (cmd/ctrl + scroll → vertical pan) or a trackpad pinch, which also sets ctrlKey (→ zoom at cursor). Either way it bypasses scrollable children and acts on the canvas.
const isModifierWheel = e.ctrlKey || e.metaKey;
// Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary.
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
@@ -256,7 +299,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
scrollableCache.set(target, cls);
}
if (cls === 'scrollable' && !isPinchZoom) {
if (cls === 'scrollable' && !isModifierWheel) {
// Google Maps model: plain scroll zooms the canvas over a CARD (chat, scheduled task) UNLESS you've clicked INTO it. Only a card that isn't scroll-focused diverts to zoom; non-card scrollable UI (dropdowns, menus, nested panels) always scrolls natively, and a focused card scrolls its content.
const cardEl = target.closest('[data-select-id]');
const cardId = cardEl?.getAttribute('data-select-id') ?? null;
if (cardId && cardId !== getScrollFocusedCard()) {
target = target.parentElement;
continue;
}
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
@@ -289,16 +339,25 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
inertiaFrameRef.current = null;
}
if (isPinchZoom) {
// Pinch gesture → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time.
if (isModifierWheel && cmdRef.current) {
// Real cmd/ctrl physically held + scroll → vertical pan. cmdRef is set from a keydown; a trackpad pinch sets ctrlKey with no keydown, so it falls through to the zoom branch below and pinch-to-zoom survives.
pendingPanDy += dy;
scheduleWheelFlush();
} else if (isModifierWheel) {
// Trackpad pinch → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time.
const rect = el.getBoundingClientRect();
pendingZoomDy += dy;
pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
scheduleWheelFlush();
} else {
// Two-finger scroll → accumulate pan deltas.
} else if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal-dominant scroll → pan X; it's the only horizontal-pan gesture. Dominant-axis, so the vertical jitter in a sideways swipe doesn't also zoom.
pendingPanDx += dx;
pendingPanDy += dy;
scheduleWheelFlush();
} else {
// Plain vertical scroll → zoom at the cursor (same anchor as pinch) so the point under the pointer grows toward you, not away. Clamp the per-event delta so a discrete mouse notch is a small step, not a lurch.
const rect = el.getBoundingClientRect();
pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP);
pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
scheduleWheelFlush();
}
};
@@ -346,7 +405,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
// Don't leave the flag stuck on if the canvas unmounts mid-gesture.
setCanvasInteractionActive(false);
};
}, [enabled]);
}, [enabled, applyLive, commitLive]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -371,12 +430,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const start = panStartRef.current;
const latest = latestDragRef.current;
if (!start || !latest) return;
setState((prev) => ({
...prev,
applyLive({
...stateRef.current,
panX: start.panX + latest.dx,
panY: start.panY + latest.dy,
}));
}, []);
});
}, [applyLive]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const start = panStartRef.current;
@@ -427,11 +486,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
panStartRef.current = null;
setIsPanning(false);
setCanvasInteractionActive(false);
// Inertia keeps writing live and commits when it settles; otherwise this gesture ends here.
if (!didInertia) commitLive();
// Only spring back if we were actually panning (not on simple clicks)
if (wasPanning && !didInertia) {
springBackIfNeeded();
}
}, [startInertia, springBackIfNeeded]);
}, [startInertia, springBackIfNeeded, commitLive]);
// Clean up panning if mouse leaves the window
useEffect(() => {
@@ -440,11 +501,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
panStartRef.current = null;
setIsPanning(false);
setCanvasInteractionActive(false);
commitLive();
}
};
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, []);
}, [commitLive]);
useEffect(() => {
return () => { cancelAnimation(); cancelInertia(); };
@@ -641,7 +703,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
if (!target) {
// 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 });
setCanvasState({ panX: 0, panY: 0, zoom: 1 });
}
return;
}
@@ -651,7 +713,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
animateTo(target, FIT_DURATION);
// 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;
@@ -662,13 +724,53 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
Math.abs(cur2.panX - fresh.panX) +
Math.abs(cur2.panY - fresh.panY) +
Math.abs(cur2.zoom - fresh.zoom) * 1000;
if (drift > 8) setState(fresh);
}, 370);
if (drift > 8) setCanvasState(fresh);
}, FIT_SETTLE_DELAY);
} else {
setState(target);
setCanvasState(target);
}
},
[cancelAnimation, animateTo, computeFitTarget],
[cancelAnimation, animateTo, computeFitTarget, setCanvasState],
);
// Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom.
const revealCards = useCallback(
(cardRects: Array<{ x: number; y: number; width: number; height: number }>) => {
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) return;
const v = viewport.getBoundingClientRect();
if (v.width <= 0 || v.height <= 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of cardRects) {
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.width);
maxY = Math.max(maxY, r.y + r.height);
}
if (!isFinite(minX)) return;
const REVEAL_MARGIN = 48;
const cur = stateRef.current;
const fitZoom = Math.min(
(v.width - REVEAL_MARGIN * 2) / (maxX - minX),
(v.height - REVEAL_MARGIN * 2) / (maxY - minY),
);
const zoom = clamp(Math.min(cur.zoom, fitZoom), MIN_ZOOM, MAX_ZOOM);
// If zooming out, keep the viewport-center world point fixed first, then clamp.
const ratio = zoom / cur.zoom;
let panX = v.width / 2 - (v.width / 2 - cur.panX) * ratio;
let panY = v.height / 2 - (v.height / 2 - cur.panY) * ratio;
const left = minX * zoom + panX, right = maxX * zoom + panX;
if (left < REVEAL_MARGIN) panX += REVEAL_MARGIN - left;
else if (right > v.width - REVEAL_MARGIN) panX -= right - (v.width - REVEAL_MARGIN);
const top = minY * zoom + panY, bottom = maxY * zoom + panY;
if (top < REVEAL_MARGIN) panY += REVEAL_MARGIN - top;
else if (bottom > v.height - REVEAL_MARGIN) panY -= bottom - (v.height - REVEAL_MARGIN);
const cur2 = stateRef.current;
if (Math.abs(panX - cur2.panX) < 2 && Math.abs(panY - cur2.panY) < 2 && Math.abs(zoom - cur2.zoom) < 0.005) return;
cancelAnimation();
animateTo({ panX, panY, zoom }, FIT_DURATION);
},
[cancelAnimation, animateTo],
);
const handlers = useMemo(() => ({
@@ -677,9 +779,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
onMouseUp: handleMouseUp,
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
// Per-frame pan for edge-pan-during-card-drag: live-only, the caller commits when the drag ends.
const panBy = useCallback((dx: number, dy: number) => {
const prev = stateRef.current;
applyLive({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy });
}, [applyLive]);
const getLiveState = useCallback((): CanvasState => stateRef.current, []);
const actions = useMemo(() => ({
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]);
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation,
setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
return {
...state,
@@ -688,6 +799,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
cmdHeld,
viewportRef,
contentRef,
gridRef,
handlers,
actions,
} as const;
@@ -8,9 +8,6 @@ import type { CanvasActions } from './useCanvasControls';
type Selection = ReturnType<typeof useDashboardSelection>;
interface UseCardDragArgs {
panX: number;
panY: number;
zoom: number;
viewportRef: RefObject<HTMLDivElement | null>;
canvasActions: CanvasActions;
selection: Selection;
@@ -27,20 +24,12 @@ function axisIntensity(pos: number, lo: number, hi: number): number {
}
export function useCardDrag({
panX,
panY,
zoom,
viewportRef,
canvasActions,
selection,
}: UseCardDragArgs) {
const dispatch = useAppDispatch();
// Notify the currently dragging card (if any) that pan/zoom changed so it can re-pin to the cursor. useEffect rather than render-body dispatchEvent: side effects during render are a React anti-pattern and can fire twice in strict mode. Effect runs after commit, so exactly once per real pan/zoom delta. Edge-pan mutates pan via canvasActions.setState below, so the dispatch lives in the same hook.
useEffect(() => {
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
}, [panX, panY, zoom]);
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null);
const activeDragCardRef = useRef<string | null>(null);
@@ -69,22 +58,20 @@ export function useCardDrag({
const dy = EDGE_MAX_SPEED * axisIntensity(my, rect.top, rect.bottom);
if (dx !== 0 || dy !== 0) {
canvasActions.setState((prev: { panX: number; panY: number; zoom: number }) => ({
...prev,
panX: prev.panX + dx,
panY: prev.panY + dy,
}));
// Live-only write (no React commit per frame); clearDrag commits once when the drag ends.
canvasActions.panBy(dx, dy);
}
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
}, [viewportRef, canvasActions]);
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
const handleCardDragStart = useCallback((id: string, type: CardType) => {
activeDragCardRef.current = id;
if (selection.isSelected(id)) {
isMultiDragRef.current = true;
} else {
selection.deselectAll();
// Grabbing an unselected card SELECTS just it (was deselectAll, which left nothing selected, so the next spawn had no anchor and flew to viewport-center far from the card you just moved). Also survives the stale-read where the capture-phase click already selected it.
selection.selectCard(id, type, false);
isMultiDragRef.current = false;
}
}, [selection]);
@@ -93,6 +80,8 @@ export function useCardDrag({
if (mouseX !== undefined && mouseY !== undefined) {
lastMousePosRef.current = { x: mouseX, y: mouseY };
}
// Arm the webview shield on the first real MOVE, not on pointerdown: a plain click also arms the drag machinery, and shielding then made the click-to-focus camera fit skip (it saw a "drag in progress"), so focusing a card took two clicks. On a real drag the shield still goes up before the pointer travels, so the webview neutralization + no-nudge + release-over-webview fixes all hold. Idempotent add.
document.body.classList.add('dashboard-marquee-active');
// Start edge panning only once actual dragging begins; a live frame handle means the loop is already running.
if (edgePanFrameRef.current === null) {
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
@@ -107,11 +96,14 @@ export function useCardDrag({
const clearDrag = useCallback(() => {
stopEdgePan();
// Reconcile React with whatever edge-pan wrote live during the drag.
canvasActions.commit();
activeDragCardRef.current = null;
document.body.classList.remove('dashboard-marquee-active');
isMultiDragRef.current = false;
setMultiDragDelta(null);
setLiveDragInfo(null);
}, [stopEdgePan]);
}, [stopEdgePan, canvasActions]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (didDrag) report('dashboard', 'card_dragged');
@@ -51,6 +51,9 @@ export function useDashboardClipboard({
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'c') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
// Highlighted text owns Cmd+C (OS semantics). Without this, clicking a chat selects the CARD, so copying a highlighted message overwrote the clipboard with the card's NAME (the "I copied text but pasted the chat title" bug).
const textSel = window.getSelection();
if (textSel && !textSel.isCollapsed && textSel.toString().trim()) return;
if (selection.selectedIds.size === 0) return;
e.preventDefault();
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { collapseSession, expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import type { useCanvasControls } from './useCanvasControls';
@@ -20,6 +22,19 @@ function isCardTarget(target: EventTarget | null, boundary: EventTarget | null):
return false;
}
const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A', 'WEBVIEW']);
// True when the press landed on a real control (text field, button, browser URL bar/tabs, note textarea, webview) rather than the card's frame. Walk up ONLY to the card root so a button living above the card never counts.
function pressLandedOnControl(target: EventTarget | null | undefined): boolean {
let el = target as HTMLElement | null;
while (el) {
if (el.hasAttribute(SELECT_ATTR)) return false;
if (CONTROL_TAGS.has(el.tagName) || el.isContentEditable || el.getAttribute('role') === 'button') return true;
el = el.parentElement;
}
return false;
}
interface UseDashboardInteractionsArgs {
canvas: Canvas;
selection: Selection;
@@ -42,7 +57,7 @@ export function useDashboardInteractions({
// Delay single-click collapse so double-click can override
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => {
report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey });
if (shiftKey) {
selection.selectCard(id, type, true);
@@ -52,6 +67,9 @@ export function useDashboardInteractions({
selection.selectCard(id, type, false);
dispatch(bringToFront({ id, type }));
// Clicking a control INSIDE a card (text field, button, browser URL bar/tabs, note textarea) selects + raises it but must NOT re-center the camera onto it: yanking focus to a card just to click into its input is hostile (same reasoning as the guest-page and Workflows carve-outs). Card frame/body clicks still auto-focus.
if (pressLandedOnControl(originTarget)) return;
// The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom.
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
@@ -72,6 +90,8 @@ export function useDashboardInteractions({
}
setFocusedCardId(id);
setTimeout(() => {
// The capture-phase select fires this on pointer DOWN; if the press became a drag (or marquee), re-framing the camera mid-gesture is the "canvas yanks as I start dragging" nudge. The webview shield class is up for exactly that window.
if (document.body.classList.contains('dashboard-marquee-active')) return;
const rect = getCardRect(id, type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined);
setTimeout(() => {
@@ -87,6 +107,8 @@ export function useDashboardInteractions({
const handleBringToFront = useCallback((id: string, type: CardType) => {
dispatch(bringToFront({ id, type }));
// Pressing ANY part of a card (header, body, composer) focuses it for scrolling, so its content scrolls instead of the canvas zooming (Google Maps model). Fires via onPointerDownCapture on every card, so a click into a chat's composer focuses it even though the body swallows the bubble. Cleared on blank-canvas press.
setScrollFocusedCard(id);
}, [dispatch]);
// A click INSIDE a webview's page never reaches the host DOM; BrowserCard forwards the guest's app-clicked IPC as this event. Select + raise only, no camera fit: you're clicking around inside the page, re-framing the canvas every tap would be hostile (same carve-out as the Workflows window).
@@ -94,8 +116,20 @@ export function useDashboardInteractions({
const onGuestSelect = (e: Event) => {
const browserId = (e as CustomEvent).detail?.browserId;
if (typeof browserId !== 'string' || !browserId) return;
// Mid-drag/marquee a selection change joins the card to the multi-drag (the browser visibly chased the cursor); the shield class is up for exactly that window.
if (document.body.classList.contains('dashboard-marquee-active')) return;
// The guest preload fires app-clicked for the AGENT's clicks too; a working agent driving its own page must not steal selection (it also re-anchored spawn-beside onto its browser).
const st = store.getState();
const working = (s?: { status?: string }) => !!s && (s.status === 'running' || s.status === 'waiting_approval');
const glow = st.dashboardLayout.glowingBrowserCards[browserId];
const agentDriven =
Object.values(st.agents.sessions).some((s) => s.browser_id === browserId && working(s)) ||
(!!glow && !glow.fading && working(st.agents.sessions[glow.sourceId]));
if (agentDriven) return;
selection.selectCard(browserId, 'browser', false);
dispatch(bringToFront({ id: browserId, type: 'browser' }));
// In-guest clicks never reach the host capture handler, so mark the browser focused here, mainly to UN-focus any chat so scroll over other cards behaves right (the browser's own page scroll/zoom is native regardless).
setScrollFocusedCard(browserId);
};
window.addEventListener('openswarm:browser-guest-select', onGuestSelect);
return () => window.removeEventListener('openswarm:browser-guest-select', onGuestSelect);
@@ -117,6 +151,9 @@ export function useDashboardInteractions({
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
// Clicking blank canvas leaves every card: plain scroll zooms the canvas again (Google Maps model).
setScrollFocusedCard(null);
// Canvas click, drop any lingering input focus so arrow-key nav works immediately without the user having to press Escape first.
const active = document.activeElement as HTMLElement | null;
const activeTag = active?.tagName;
@@ -198,7 +198,7 @@ export function useAgentSpawn({
if (bc) rects.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height });
}
}
canvasActions.fitToCards(rects, 1.15, true, undefined, true);
canvasActions.revealCards(rects);
handleHighlightCard(draftId);
}
@@ -65,7 +65,7 @@ export function useDashboardCardActions({
}
const card = viewCards[focusKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true, undefined, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(focusKey);
}
}, 200);
@@ -88,7 +88,7 @@ export function useDashboardCardActions({
const newId = Object.keys(allNotes).find((id) => !prevIds.has(id));
if (newId) {
const note = allNotes[newId];
canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true, undefined, true);
canvasActions.revealCards([{ x: note.x, y: note.y, width: note.width, height: note.height }]);
handleHighlightCard(newId);
}
}, 200);
@@ -109,7 +109,7 @@ export function useDashboardCardActions({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[sessionId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(sessionId);
}
}, 200);
@@ -114,13 +114,16 @@ export function useDashboardLifecycle({
useEffect(() => {
if (!dashboardId) return;
hasFittedRef.current = false;
restoredExpandedRef.current = false;
setOutputsRefetched(false);
dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() }));
// CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer.
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout({ dashboardId }));
// Never wipe+reload the layout while a card drag or marquee is in flight: a spurious mid-gesture nav (e.g. a phantom-dashboard round-trip) would unmount the card under the cursor and the drag silently dies. You can't switch dashboards while holding a drag, so any reset firing now is spurious. The shield class is up for exactly that window. Handlers below still install.
if (!document.body.classList.contains('dashboard-marquee-active')) {
hasFittedRef.current = false;
restoredExpandedRef.current = false;
setOutputsRefetched(false);
dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() }));
// CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer.
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout({ dashboardId }));
}
const cleanupBrowserHandler = initBrowserCommandHandler();
// Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return.
const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => {
@@ -216,7 +219,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[agentId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(agentId);
}
}, 350);
@@ -232,13 +235,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.browserCards[browserId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
0.8,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(browserId);
}
}, 200);
@@ -254,7 +251,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[cardKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(cardKey);
}
}, 200);
@@ -269,11 +266,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.workflowCards[workflowId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(workflowId);
}
}, 200);
@@ -366,7 +359,7 @@ export function useDashboardLifecycle({
const ac = store.getState().dashboardLayout.cards[sid];
if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height });
}
canvasActions.fitToCards(rects, 1.15, true);
canvasActions.revealCards(rects);
handleHighlightCard(outputId);
}, 200);
}
@@ -380,7 +373,10 @@ export function useDashboardLifecycle({
if (!dash) return;
if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return;
const hasUserMessage = Object.values(sessions).some(
(s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'),
(s) => s.dashboard_id === dashboardId && (
s.messages?.some((m) => m.role === 'user') ||
(s.messages.length === 0 && !!s.first_user_message)
),
);
if (!hasUserMessage) return;
namedOnFirstMessageRef.current = dashboardId;
@@ -94,10 +94,11 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
setNewAgentBounce(canvasEmpty && !bounceDismissedRef.current);
}, [canvasEmpty, setNewAgentBounce]);
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
// Stable getter, AgentCards read pan/zoom on demand during drag math.
const getCanvasState = useCallback(() => canvasStateRef.current, []);
// Live camera reads: gestures write the transform imperatively and only commit React state at gesture-end, so a render-synced ref would be stale mid-edge-pan (drag math) and inside the 140ms wheel-settle window (spawn placement). Both delegate to the canvas hook's live truth.
const getCanvasState = useCallback(() => canvas.actions.getLiveState(), [canvas.actions]);
const canvasStateRef = useMemo(() => ({
get current() { return canvas.actions.getLiveState(); },
}), [canvas.actions]);
const {
multiDragDelta,
@@ -106,9 +107,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
handleCardDragMove,
handleCardDragEnd,
} = useCardDrag({
panX: canvas.panX,
panY: canvas.panY,
zoom: canvas.zoom,
viewportRef: canvas.viewportRef,
canvasActions: canvas.actions,
selection,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CardPosition } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { useDashboardSelection } from './useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -45,6 +46,8 @@ export function useDashboardUiState(selection: Selection, cards: Record<string,
if (!cards[pendingSelectSessionId]) return;
setPendingSelectSessionId(null);
selection.selectCard(pendingSelectSessionId, 'agent', false);
// A freshly spawned chat is the active one: focus it for scrolling so its transcript scrolls immediately (Google Maps gate) instead of zooming the canvas until the user clicks it.
setScrollFocusedCard(pendingSelectSessionId);
}, [pendingSelectSessionId, cards, selection]);
const spawnOriginsRef = useRef<Record<string, SpawnOrigin>>({});
+2
View File
@@ -33,6 +33,7 @@ import { Integration, INTEGRATIONS } from './integrations';
import { CATEGORY_ORDER } from './toolsHelpers';
import ToolSection from './cards/ToolSection';
import BrowserPermissionCard from './cards/BrowserPermissionCard';
import AgentWorkflowsSection from './cards/AgentWorkflowsSection';
import RegistryBrowserDialog from './dialogs/RegistryBrowserDialog';
import ToolDialogs from './dialogs/ToolDialogs';
import CustomToolCard from './cards/CustomToolCard';
@@ -178,6 +179,7 @@ const Tools: React.FC = () => {
</Box>
</Collapse>
</Box>
<AgentWorkflowsSection />
<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 } }}>
@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import Switch from '@mui/material/Switch';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchWorkflows, updateWorkflow } from '@/shared/state/workflowsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Actions-page section: per-workflow opt-in that lets agents run the workflow via the InvokeWorkflow tool.
const AgentWorkflowsSection: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const [open, setOpen] = useState(true);
useEffect(() => {
dispatch(fetchWorkflows(undefined));
}, [dispatch]);
const list = Object.values(workflows).filter((w) => !w.deleted_at && !w.unsaved);
const exposedCount = list.filter((w) => w.exposed_as_tool).length;
if (list.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<Box
onClick={() => setOpen((v) => !v)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}
>
{open ? <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' }} />}
<AccountTreeIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Workflows agents can run</Typography>
<Chip label={`${exposedCount}/${list.length}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={open} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 1 }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mb: 0.5 }}>
Enabled workflows can be run by your agents as a tool (InvokeWorkflow); the agent waits for the run and reads its result.
</Typography>
{list.map((w) => (
<Box key={w.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1, borderRadius: 1, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.title || 'Untitled workflow'}</Typography>
{w.description && (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</Typography>
)}
</Box>
<Switch
size="small"
checked={!!w.exposed_as_tool}
onChange={(e) => dispatch(updateWorkflow({ id: w.id, patch: { exposed_as_tool: e.target.checked } }))}
/>
</Box>
))}
</Box>
</Collapse>
</Box>
);
};
export default AgentWorkflowsSection;
@@ -7,6 +7,7 @@ import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { registerViewWebview, unregisterViewWebview, type ViewWebview } from '@/shared/viewWebviewRegistry';
import { registerViewFrame, unregisterViewFrame } from '@/shared/viewFrameRegistry';
import RunInDesktopMessage from '@/app/components/RunInDesktopMessage';
import { registerWebview, unregisterWebview, setActiveTab, type BrowserWebview } from '@/shared/browserRegistry';
@@ -324,6 +325,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return () => unregisterViewWebview(registryId);
}, [useWebview, registryId, iframeSrc]);
// Same registration for the srcdoc path, so the dashboard's arrow keys can reach a non-webview app card's content. Re-runs on reloadKey because a reload swaps the element.
useEffect(() => {
if (useWebview || !registryId) return;
const frame = iframeRef.current;
if (!frame) return;
registerViewFrame(registryId, frame);
return () => unregisterViewFrame(registryId);
}, [useWebview, registryId, iframeSrc, reloadKey]);
// Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state.
const interactiveRef = useRef(interactive);
interactiveRef.current = interactive;
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { deleteWorkflow } from '@/shared/state/workflowsSlice';
import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
import ShareButton from '@/app/components/share/ShareButton';
import { colorForWorkflow, useWC } from './uiKit';
import WorkflowTitle from './WorkflowTitle';
import type { AppNav } from './types';
@@ -18,6 +19,7 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
const items = useAppSelector((s) => s.workflows.items);
const trashCount = useAppSelector((s) => s.workflows.deleted.length);
const [query, setQuery] = useState('');
const [hovered, setHovered] = useState<string | null>(null);
const workflows = useMemo(() => Object.values(items)
.filter((w) => !w.unsaved)
@@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div
key={w.id}
onClick={() => nav.selectWorkflow(w.id)}
onMouseEnter={() => setHovered(w.id)}
onMouseLeave={() => setHovered((h) => (h === w.id ? null : h))}
style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '5px 9px', borderRadius: 8, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
{active ? describeSchedule(w.schedule) : 'Paused'}
</div>
</div>
{/* Faded rather than unmounted on hover-out: ShareButton owns the modal's open state, so unmounting it would close the modal the moment the pointer left the row for the dialog. Also keeps the row from reflowing on hover. */}
<span
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flex: 'none',
opacity: hovered === w.id ? 1 : 0,
pointerEvents: hovered === w.id ? 'auto' : 'none',
transition: 'opacity 0.12s',
}}
>
<ShareButton
target={{ kind: 'workflow', id: w.id, name: w.title || 'Untitled workflow' }}
iconFontSize={13}
/>
</span>
<div
onClick={(e) => { e.stopPropagation(); onDelete(w.id); }}
style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }}
@@ -34,16 +34,14 @@ interface Props {
cardWidth: number;
cardHeight: number;
cardZOrder: number;
zoom: number;
panX: number;
panY: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
}
// The live run view, a real canvas card (standard claudeTokens chrome) spawned beside the Workflows window. The orange connector back to the window is drawn by the shared TetherLayer, same mechanism as an agent spinning up a browser.
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => {
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, getCanvasState, onDragStart, onDragMove, onDragEnd }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
@@ -53,10 +51,6 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
useEffect(() => { dispatch(fetchRuns(workflow.id)); }, [workflow.id, dispatch]);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const dragState = useRef<{ sx: number; sy: number; ox: number; oy: number; spx: number; spy: number } | null>(null);
const didDrag = useRef(false);
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
@@ -67,11 +61,12 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
if (t.closest('button, [role="button"]')) return;
e.preventDefault(); e.stopPropagation();
dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }));
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: panRef.current.panX, spy: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: cs.panX, spy: cs.panY };
didDrag.current = false;
onDragStart('workflows-monitor', 'workflows-monitor');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, dispatch, onDragStart]);
}, [cardX, cardY, dispatch, onDragStart, getCanvasState]);
const onHeaderMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -79,21 +74,23 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
const rdy = e.clientY - dragState.current.sy;
if (!didDrag.current && Math.sqrt(rdx * rdx + rdy * rdy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const cs = getCanvasState();
const z = cs.zoom;
const pdx = (cs.panX - dragState.current.spx) / z;
const pdy = (cs.panY - dragState.current.spy) / z;
const dx = rdx / z - pdx;
const dy = rdy / z - pdy;
setLocalPos({ x: dragState.current.ox + dx, y: dragState.current.oy + dy });
// Feed the shared drag channel so the tether tracks live, same as cards.
onDragMove(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
const onHeaderUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const cs = getCanvasState();
const z = cs.zoom;
const pdx = (cs.panX - dragState.current.spx) / z;
const pdy = (cs.panY - dragState.current.spy) / z;
const dx = (e.clientX - dragState.current.sx) / z - pdx;
const dy = (e.clientY - dragState.current.sy) / z - pdy;
if (didDrag.current) {
@@ -107,7 +104,7 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
didDrag.current = false;
setLocalPos(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
}, [dispatch, onDragEnd, getCanvasState]);
// A pinned run id (clicked from history) wins; otherwise follow the latest run.
const run: WorkflowRun | null =
@@ -1,11 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useWC, FONT_SERIF } from './uiKit';
import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import { useWC } from './uiKit';
import WorkflowsAppContent from './WorkflowsAppContent';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -38,9 +34,7 @@ interface Props {
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
@@ -53,18 +47,13 @@ interface Props {
const WorkflowsAppCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
// ---- Drag (title bar is the handle) ----
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
@@ -82,12 +71,13 @@ const WorkflowsAppCard: React.FC<Props> = ({
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
didDrag.current = false;
setIsDragging(true);
onDragStart?.('workflows-hub', 'workflows-hub');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
}, [cardX, cardY, onDragStart, getCanvasState]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -95,20 +85,22 @@ const WorkflowsAppCard: React.FC<Props> = ({
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -125,7 +117,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
}, [dispatch, onDragEnd, getCanvasState]);
// ---- Resize ----
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
@@ -144,8 +136,9 @@ const WorkflowsAppCard: React.FC<Props> = ({
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const dx = (e.clientX - sx0) / zoomRef.current;
const dy = (e.clientY - sy0) / zoomRef.current;
const z2 = getCanvasState().zoom;
const dx = (e.clientX - sx0) / z2;
const dy = (e.clientY - sy0) / z2;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
@@ -216,31 +209,14 @@ const WorkflowsAppCard: React.FC<Props> = ({
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<WorkflowsAppContent />
<WorkflowsAppContent
header={{
onPointerDown: onHeaderPointerDown,
onPointerMove: onHeaderPointerMove,
onPointerUp: onHeaderPointerUp,
dragging: isDragging,
}}
/>
{HANDLE_DEFS.map(({ dir, css }) => (
<div
@@ -1,12 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice';
import { clearWorkflowsAppTarget, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import {
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows,
} from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { FONT_SANS, useWC } from './uiKit';
import type { AppMode, CalView, AppNav } from './types';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareButton from '@/app/components/share/ShareButton';
import { FONT_SANS, FONT_SERIF, useWC } from './uiKit';
import type { AppMode, CalView, AppNav, CardHeader } from './types';
import LeftRail from './LeftRail';
import HomeView from './HomeView';
import CalendarView from './CalendarView';
@@ -14,9 +19,10 @@ import DetailView from './DetailView';
import ComposeView from './ComposeView';
import TrashView from './TrashView';
// The three-pane Workflows body, independent of how it's framed (canvas card). Holds nav + data; the card chrome (title bar drag handle, resize) wraps it.
const WorkflowsAppContent: React.FC = () => {
// The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open.
const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget);
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
@@ -26,6 +32,10 @@ const WorkflowsAppContent: React.FC = () => {
const [calView, setCalView] = useState<CalView>('month');
const [refDate, setRefDate] = useState<Date>(() => new Date());
// goHome leaves selectedId set, so gate on the mode too or Share lingers in the title bar after leaving the workflow.
const shared = useAppSelector((s) => (selectedId ? s.workflows.items[selectedId] : undefined));
const selected = mode === 'detail' ? shared : undefined;
useEffect(() => {
dispatch(fetchWorkflows(dashboardId));
dispatch(fetchAllRuns(200));
@@ -56,13 +66,53 @@ const WorkflowsAppContent: React.FC = () => {
}), [mode, selectedId, calView, refDate, dashboardId, dispatch]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={header.onPointerDown}
onPointerMove={header.onPointerMove}
onPointerUp={header.onPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: header.dragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
{selected && (
// The share dialog portals to the body but its events still bubble the React tree, so stop them here or dragging the card follows a click inside the modal.
<span
data-no-drag
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{ display: 'flex' }}
>
<ShareButton
target={{ kind: 'workflow', id: selected.id, name: selected.title || 'Untitled workflow' }}
iconFontSize={17}
/>
</span>
)}
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
</div>
</div>
);
};
@@ -1,6 +1,16 @@
import type { PointerEvent } from 'react';
export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash';
export type CalView = 'week' | 'month';
// The card owns drag geometry but the title bar renders inside the content (it needs nav state to know which workflow to share), so the card hands its drag handlers down.
export interface CardHeader {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
dragging: boolean;
}
// Navigation + ephemeral UI state for the Workflows app window. Data lives in Redux; this is only "where am I looking right now".
export interface AppNav {
mode: AppMode;
+128 -15
View File
@@ -1,4 +1,4 @@
import { getWebview, findWebviewByDomain, type BrowserWebview } from './browserRegistry';
import { getWebview, findWebviewByDomain, hasDomReady, markDomReady, isPendingLoad, wakePendingLoad, clearPendingLoad, type BrowserWebview } from './browserRegistry';
import { store } from './state/store';
import { resumeBrowserCard } from './state/dashboardLayoutSlice';
import { dashboardWs } from './ws/WebSocketManager';
@@ -189,8 +189,40 @@ async function countSafeRoutes(wv: BrowserWebview): Promise<number> {
} catch { return 0; }
}
// Electron queues executeJavaScript until the page "stops loading", and pages with straggler subresources (recaptcha/tracker iframes) can stay isLoading for minutes, starving EVERY command into its backend timeout (the wedged-webview tail). Once the document itself is ready, wv.stop() cancels only the stragglers and fires did-stop-loading, which flushes the queue; a genuinely-still-loading document (no dom-ready yet) is left alone.
const STUCK_EVAL_GRACE_MS = 2500;
const STUCK_EVAL_LIMIT_MS = 9000;
async function evalInPage(wv: BrowserWebview, code: string): Promise<any> {
const run = wv.executeJavaScript(code).then((v) => {
markDomReady(wv);
return { done: true as const, value: v };
});
const grace = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_GRACE_MS));
let first = await Promise.race([run, grace]);
if (!first.done) {
let stopped = false;
try {
if (wv.isLoading() && hasDomReady(wv)) {
wv.stop();
stopped = true;
}
} catch {
// torn-down webview; the limit below surfaces it
}
const limit = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_LIMIT_MS));
first = await Promise.race([run, limit]);
if (!first.done) {
throw new Error(stopped
? 'page never finished loading even after cancelling stragglers'
: 'page is still loading; retry shortly');
}
}
return first.value;
}
async function handleGetText(wv: BrowserWebview): Promise<Record<string, any>> {
const text: string = await wv.executeJavaScript(
const text: string = await evalInPage(wv,
'document.body.innerText.substring(0, 15000)'
);
// Sampled HERE (on a read), not on navigate: by the time the agent reads the page, the SPA's XHR/fetch have fired, so routes are actually captured.
@@ -272,7 +304,7 @@ async function handleClick(wv: BrowserWebview, params: Record<string, any>): Pro
clickY: window.innerHeight > 0 ? y / window.innerHeight : 0.5,
};
})()`;
const result = await wv.executeJavaScript(code);
const result = await evalInPage(wv, code);
return result;
}
@@ -300,7 +332,7 @@ async function handleType(wv: BrowserWebview, params: Record<string, any>): Prom
text: 'Typed into: ' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
};
})()`;
const result = await wv.executeJavaScript(code);
const result = await evalInPage(wv, code);
return result;
}
@@ -316,12 +348,63 @@ const KEY_NAME_MAP: Record<string, string> = {
Del: 'Delete',
};
interface CdpKeyDescriptor { key: string; code: string; vk: number; text?: string }
const CDP_KEYS: Record<string, CdpKeyDescriptor> = {
Enter: { key: 'Enter', code: 'Enter', vk: 13, text: '\r' },
Tab: { key: 'Tab', code: 'Tab', vk: 9 },
Escape: { key: 'Escape', code: 'Escape', vk: 27 },
Backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },
Delete: { key: 'Delete', code: 'Delete', vk: 46 },
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },
Home: { key: 'Home', code: 'Home', vk: 36 },
End: { key: 'End', code: 'End', vk: 35 },
PageUp: { key: 'PageUp', code: 'PageUp', vk: 33 },
PageDown: { key: 'PageDown', code: 'PageDown', vk: 34 },
' ': { key: ' ', code: 'Space', vk: 32, text: ' ' },
};
// Loose names the model actually sends, folded onto the canonical DOM names above.
const CDP_KEY_ALIASES: Record<string, string> = {
Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight',
Space: ' ', Spacebar: ' ', Esc: 'Escape', Del: 'Delete', Return: 'Enter',
};
function cdpKeyDescriptor(rawKey: string): CdpKeyDescriptor | null {
const canonical = CDP_KEY_ALIASES[rawKey] || rawKey;
const named = CDP_KEYS[canonical];
if (named) return named;
if (canonical.length === 1) {
const upper = canonical.toUpperCase();
const code = /[a-z]/i.test(canonical) ? `Key${upper}` : /[0-9]/.test(canonical) ? `Digit${canonical}` : '';
return { key: canonical, code, vk: upper.charCodeAt(0), text: canonical };
}
return null;
}
async function handlePressKey(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const rawKey = (params.key as string) || '';
if (!rawKey) return { error: 'key parameter is required' };
await evalInPage(wv, 'document.body && document.body.focus && document.body.focus(); true');
const desc = cdpKeyDescriptor(rawKey);
if (desc) {
try {
// CDP key events are trusted AND scoped to THIS webview no matter where the user's cursor sits; the sendInputEvent path delivered to whatever had focus, which is the "agent typed into my note" bug. keyDown-with-text inserts the char; bare named keys use rawKeyDown so no stray char lands.
const down: Record<string, any> = { type: desc.text ? 'keyDown' : 'rawKeyDown', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
if (desc.code) down.code = desc.code;
if (desc.text) down.text = desc.text;
await sendCdp(wv, 'Input.dispatchKeyEvent', down);
const up: Record<string, any> = { type: 'keyUp', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
if (desc.code) up.code = desc.code;
await sendCdp(wv, 'Input.dispatchKeyEvent', up);
return { text: `Pressed ${rawKey}` };
} catch { /* fall through to the legacy path so a CDP hiccup never makes a key dead */ }
}
// Legacy focus-dependent fallback (exotic keys or CDP unavailable): keeps every key that worked before working.
const keyCode = KEY_NAME_MAP[rawKey] || rawKey;
await wv.executeJavaScript('document.body && document.body.focus && document.body.focus(); true');
// 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 });
@@ -348,7 +431,7 @@ async function handleClickPoint(wv: BrowserWebview, params: Record<string, any>)
// host element's box. One cheap round-trip; falls back to the element box.
let vw = wv.clientWidth, vh = wv.clientHeight;
try {
const d = await wv.executeJavaScript('({w: window.innerWidth, h: window.innerHeight})');
const d = await evalInPage(wv, '({w: window.innerWidth, h: window.innerHeight})');
if (d && d.w > 0 && d.h > 0) { vw = d.w; vh = d.h; }
} catch { /* use the element box as a fallback */ }
const x = (cx / 100) * vw;
@@ -1122,7 +1205,7 @@ async function handleScroll(wv: BrowserWebview, params: Record<string, any>): Pr
};
})()`;
try {
const result = await wv.executeJavaScript(code);
const result = await evalInPage(wv, code);
const status = result.atBottom ? ' (reached bottom)' : result.atTop ? ' (reached top)' : '';
return {
text: `Scrolled ${direction} by ${result.scrolled}px${status}. Position: ${result.scrollTop}/${result.scrollHeight - result.clientHeight}px`,
@@ -1150,7 +1233,7 @@ async function handleWait(wv: BrowserWebview, params: Record<string, any>): Prom
const elapsed = Date.now() - start;
if (elapsed >= ms) break;
try {
const probe = JSON.parse(await wv.executeJavaScript(probeJs));
const probe = JSON.parse(await evalInPage(wv, probeJs));
probeErrors = 0;
if (probe.elems !== lastElems) { lastElems = probe.elems; elemsChangedAt = Date.now(); }
const domStable = Date.now() - elemsChangedAt;
@@ -1238,7 +1321,7 @@ async function handleGetElements(wv: BrowserWebview, params: Record<string, any>
return { elements: results, total: interactive.length, url: location.href, title: document.title };
})()`;
try {
const result = await wv.executeJavaScript(code);
const result = await evalInPage(wv, code);
return { text: JSON.stringify(result, null, 2), url: wv.getURL() };
} catch (err: any) {
return { error: `Failed to get elements: ${err?.message || String(err)}` };
@@ -1263,7 +1346,7 @@ async function handleDetectWebMCP(wv: BrowserWebview): Promise<Record<string, an
return { present: true, tools };
})()`;
try {
const r = await wv.executeJavaScript(code);
const r = await evalInPage(wv, code);
if (!r || !r.present) {
return { text: 'No WebMCP on this page (navigator.modelContext not present). Use the normal browser tools.', url: wv.getURL() };
}
@@ -1328,7 +1411,7 @@ async function handleReplayRoute(wv: BrowserWebview, params: Record<string, any>
} catch (e) { return { error: String((e && e.message) || e) }; }
})()`;
try {
const res = await wv.executeJavaScript(code);
const res = await evalInPage(wv, code);
if (res.error) return { error: `Replay failed: ${res.error}` };
return { text: `${method} ${absUrl} -> HTTP ${res.status}\n${res.body}`, status: res.status, url: wv.getURL() };
} catch (err: any) {
@@ -1340,7 +1423,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
const expression = params.expression as string;
if (!expression) return { error: 'expression parameter is required' };
try {
const result = await wv.executeJavaScript(expression);
const result = await evalInPage(wv, expression);
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
// evaluate is the agent's main read path; sample routes here too (XHRs have fired by now) so the backend can surface the fast network tier once.
const routes_available = await countSafeRoutes(wv);
@@ -1351,7 +1434,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
}
// The registry is renderer-local and a card briefly unregisters on remount / tab-switch; a command landing in that gap shouldn't hard-fail. Wait a bounded window for (re)registration before giving up, so the error stays a real "card is gone" signal rather than a transient race.
async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserWebview | undefined> {
async function awaitWebview(browserId: string, tabId?: string, action?: string): Promise<BrowserWebview | undefined> {
// A suspended (snapshot-swapped) card has no webview at all; wake it and wait out the remount + page reload before the command touches it.
const wasSuspended = !!store.getState().dashboardLayout.suspendedBrowserCards[browserId];
if (wasSuspended) store.dispatch(resumeBrowserCard(browserId));
@@ -1371,6 +1454,24 @@ async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserW
await new Promise((r) => setTimeout(r, 150));
}
}
// A lazy background tab mounts at about:blank with its real page deferred; an agent command needs
// the real page, so wake it and wait out the load, same as a resumed suspended card. A navigate
// is about to load its own url, so just drop the deferred load instead of loading the old one first.
if (wv && isPendingLoad(wv)) {
if (action === 'navigate') {
clearPendingLoad(wv);
} else if (wakePendingLoad(wv)) {
const loadDeadline = Date.now() + 12000;
while (Date.now() < loadDeadline) {
try {
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
} catch {
// mid-load hiccup; keep waiting
}
await new Promise((r) => setTimeout(r, 150));
}
}
}
return wv;
}
@@ -1418,6 +1519,18 @@ async function handlePerformAction(params: Record<string, any>): Promise<Record<
if (!wv) {
return { error: `No ${domain} browser card is open. Open ${domain} in an OpenSwarm browser card and sign in, then retry.` };
}
// findWebviewByDomain can resolve a deferred background tab by its intended url; wake it and wait out the load before driving it, so the session-borrow shims never act on an about:blank tab.
if (isPendingLoad(wv) && wakePendingLoad(wv)) {
const loadDeadline = Date.now() + 12000;
while (Date.now() < loadDeadline) {
try {
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
} catch {
// mid-load hiccup; keep waiting
}
await new Promise((res) => setTimeout(res, 150));
}
}
const steps = Array.isArray(params.steps) ? params.steps : [];
const results: Record<string, any>[] = [];
for (const step of steps) {
@@ -1447,7 +1560,7 @@ async function runBrowserCommand(
dashboardWs.send('browser:result', { request_id, ...result });
return;
}
const wv = await awaitWebview(browser_id, tab_id || undefined);
const wv = await awaitWebview(browser_id, tab_id || undefined, action);
if (!wv) {
dashboardWs.send('browser:result', {
request_id,
+67 -3
View File
@@ -20,6 +20,7 @@ export interface BrowserWebview extends HTMLElement {
reload: () => void;
canGoBack: () => boolean;
canGoForward: () => boolean;
stop: () => void;
getURL: () => string;
getTitle: () => string;
isLoading: () => boolean;
@@ -44,8 +45,61 @@ function makeKey(browserId: string, tabId: string): string {
return `${browserId}:${tabId}`;
}
// Electron suspends webContents.executeJavaScript until the page "stops loading", and pages with straggler iframes (LinkedIn's recaptcha/trackers) can stay isLoading for minutes; the guarded eval in browserCommandHandler needs to know the document itself is usable before it dares wv.stop().
const domReadyDocs = new WeakSet<BrowserWebview>();
const loadTrackingArmed = new WeakSet<BrowserWebview>();
function armLoadStateTracking(wv: BrowserWebview): void {
if (loadTrackingArmed.has(wv)) return;
loadTrackingArmed.add(wv);
wv.addEventListener('dom-ready', () => domReadyDocs.add(wv));
// a real main-frame navigation starts a new document; in-page (SPA pushState) ones don't
wv.addEventListener('did-navigate', () => domReadyDocs.delete(wv));
}
export function hasDomReady(wv: BrowserWebview): boolean {
return domReadyDocs.has(wv);
}
export function markDomReady(wv: BrowserWebview): void {
domReadyDocs.add(wv);
}
export function registerWebview(browserId: string, tabId: string, wv: BrowserWebview): void {
registry.set(makeKey(browserId, tabId), wv);
armLoadStateTracking(wv);
}
// Lazy-tab loading: a background tab mounts its <webview> (so it stays registered + resolvable
// exactly like a live one) but defers loadURL until it's actually needed, so a many-tab card
// doesn't load every page at once. The tab is never starved: it's woken when it becomes active
// OR the moment an agent command resolves it.
const pendingLoad = new WeakMap<BrowserWebview, () => void>();
const intendedUrl = new WeakMap<BrowserWebview, string>();
export function registerPendingLoad(wv: BrowserWebview, url: string, load: () => void): void {
pendingLoad.set(wv, load);
intendedUrl.set(wv, url);
}
export function isPendingLoad(wv: BrowserWebview): boolean {
return pendingLoad.has(wv);
}
// Fire a lazy tab's deferred load exactly once; returns true if it was pending (the caller then
// waits out the page load, same as a resumed suspended card). No-op on an already-loaded tab.
export function wakePendingLoad(wv: BrowserWebview): boolean {
const load = pendingLoad.get(wv);
if (!load) return false;
pendingLoad.delete(wv);
load();
return true;
}
// Drop a lazy tab's deferred load WITHOUT firing it: an agent navigate is about to load a
// different url, so loading the old intended url first would be wasted work.
export function clearPendingLoad(wv: BrowserWebview): void {
pendingLoad.delete(wv);
}
export function unregisterWebview(browserId: string, tabId: string): void {
@@ -84,13 +138,23 @@ export function findBrowserByWebContentsId(wcId: number): string | undefined {
// LIVE url (not a stale persisted card.url) so the action lands on the real tab.
export function findWebviewByDomain(domain: string): BrowserWebview | undefined {
const d = domain.toLowerCase().replace(/^\./, '');
for (const wv of registry.values()) {
const matchesHost = (u: string): boolean => {
try {
const host = new URL(wv.getURL()).hostname.toLowerCase();
if (host === d || host.endsWith('.' + d)) return wv;
const host = new URL(u).hostname.toLowerCase();
return host === d || host.endsWith('.' + d);
} catch {
// about:blank or a torn-down webview has no parseable URL; skip it.
return false;
}
};
for (const wv of registry.values()) {
if (matchesHost(wv.getURL())) return wv;
}
// A lazy background tab sits at about:blank, so its LIVE url can't match; fall back to its
// INTENDED (deferred) url so the session-borrow shims still find + wake it. The caller wakes it.
for (const wv of registry.values()) {
const pend = intendedUrl.get(wv);
if (pend && pendingLoad.has(wv) && matchesHost(pend)) return wv;
}
return undefined;
}
@@ -0,0 +1,76 @@
// Run: node --test frontend/src/shared/browserRegistryLazy.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
registerWebview,
unregisterWebview,
registerPendingLoad,
isPendingLoad,
wakePendingLoad,
clearPendingLoad,
findWebviewByDomain,
type BrowserWebview,
} from './browserRegistry.ts';
// Minimal fake webview: the registry only calls addEventListener (load tracking) + getURL.
function fakeWebview(url: string): BrowserWebview {
return {
getURL: () => url,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as BrowserWebview;
}
test('a lazy tab is resolvable by its INTENDED url while deferred, then wakes exactly once', () => {
const wv = fakeWebview('about:blank');
registerWebview('b1', 't1', wv);
let loaded = 0;
registerPendingLoad(wv, 'https://tiktok.com/@me', () => { loaded += 1; });
assert.equal(isPendingLoad(wv), true);
// about:blank live url can't match, but the intended-url fallback finds it for the session-borrow shims.
assert.equal(findWebviewByDomain('tiktok.com'), wv);
assert.equal(wakePendingLoad(wv), true);
assert.equal(loaded, 1);
// Second wake is a no-op (already loaded), so an agent re-touching the tab can't double-load it.
assert.equal(wakePendingLoad(wv), false);
assert.equal(loaded, 1);
assert.equal(isPendingLoad(wv), false);
unregisterWebview('b1', 't1');
});
test('clearPendingLoad drops the deferred load without firing it (navigate replaces the url)', () => {
const wv = fakeWebview('about:blank');
registerWebview('b2', 't2', wv);
let loaded = 0;
registerPendingLoad(wv, 'https://old.example.com', () => { loaded += 1; });
clearPendingLoad(wv);
assert.equal(isPendingLoad(wv), false);
assert.equal(wakePendingLoad(wv), false);
assert.equal(loaded, 0);
unregisterWebview('b2', 't2');
});
test('a live-url tab still matches by its real url (unchanged path)', () => {
const wv = fakeWebview('https://youtube.com/watch?v=x');
registerWebview('b3', 't3', wv);
assert.equal(findWebviewByDomain('youtube.com'), wv);
assert.equal(isPendingLoad(wv), false);
unregisterWebview('b3', 't3');
});
test('a live tab wins over a deferred tab for the same domain', () => {
const live = fakeWebview('https://reddit.com/r/x');
const lazy = fakeWebview('about:blank');
registerWebview('b4', 'live', live);
registerWebview('b4', 'lazy', lazy);
registerPendingLoad(lazy, 'https://reddit.com/r/y', () => {});
// The already-loaded tab is preferred; the deferred one is only a fallback.
assert.equal(findWebviewByDomain('reddit.com'), live);
unregisterWebview('b4', 'live');
unregisterWebview('b4', 'lazy');
});
+67
View File
@@ -0,0 +1,67 @@
import { getWebview } from './browserRegistry';
import { getViewWebview } from './viewWebviewRegistry';
import { getViewFrame } from './viewFrameRegistry';
// One arrow press moves the content about a wheel notch, so a held key and a trackpad flick cover ground at a comparable rate.
const ARROW_STEP_PX = 120;
// Walks up from whatever sits at the middle of the view (a key press has no cursor to aim with) to the first ancestor that can still scroll horizontally the way dx points, nudges it, and reports whether anything actually moved. The boundary test is the same one the wheel path uses in useCanvasControls, so keys and trackpad hand the gesture back to the canvas at the same moment.
// This runs in two worlds: stringified into a <webview> guest renderer, and called directly on a same-origin srcdoc iframe. Keep it self-contained - no imports, no closure references - or the stringified copy lands in the guest with dangling names.
function scrollContentX(doc: Document, win: Window, dx: number): boolean {
const nudge = (node: Element | null): boolean => {
if (!node) return false;
const el = node as HTMLElement;
if (el.scrollWidth <= el.clientWidth) return false;
// The document's own scroller reports overflowX 'visible' yet still scrolls, so it skips the overflow test the way a real browser does.
const isViewport = el === doc.scrollingElement;
const overflowX = win.getComputedStyle(el).overflowX;
if (!isViewport && overflowX !== 'auto' && overflowX !== 'scroll') return false;
const atRight = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
const atLeft = el.scrollLeft <= 1;
if ((dx > 0 && atRight) || (dx < 0 && atLeft)) return false;
// Instant, not smooth: a page with scroll-behavior smooth would otherwise still be animating when the next key repeat arrives.
el.scrollBy({ left: dx, behavior: 'instant' });
return true;
};
let node: Element | null = doc.elementFromPoint(
Math.floor(win.innerWidth / 2),
Math.floor(win.innerHeight / 2),
);
while (node) {
if (nudge(node)) return true;
node = node.parentElement;
}
return nudge(doc.scrollingElement);
}
// Present on real Electron webviews; a browser card falls back to a plain iframe on locked-out Windows builds, which has none of this.
interface GuestWebview {
executeJavaScript?: (code: string) => Promise<unknown>;
}
/** Scrolls a card's own content sideways. True means the card absorbed the arrow, so the dashboard must not also navigate to a neighbor. */
export async function scrollCardContentX(cardId: string, direction: 'left' | 'right'): Promise<boolean> {
const dx = direction === 'right' ? ARROW_STEP_PX : -ARROW_STEP_PX;
const guest = (getWebview(cardId) ?? getViewWebview(cardId)) as GuestWebview | undefined;
if (guest?.executeJavaScript) {
// A guest is a separate renderer: the host can't read its scrollLeft, so the whole scroll-or-boundary decision has to be made over there and come back as a yes/no.
try {
const scrolled = await guest.executeJavaScript(`(${scrollContentX})(document, window, ${dx})`);
return scrolled === true;
} catch {
return false;
}
}
// Srcdoc app card: same-origin, so the host can walk the frame's DOM directly. A cross-origin frame throws on contentWindow access; treat that as "didn't scroll" and let the arrow navigate.
const frame = getViewFrame(cardId);
try {
const win = frame?.contentWindow;
if (!win) return false;
return scrollContentX(win.document, win, dx);
} catch {
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
// The card you've clicked INTO, so plain scroll reads its content (chat transcript, scheduled-task
// list) while scroll everywhere else zooms the canvas (Google Maps model). Imperative + read on the
// wheel handler so no re-render; cleared when you click blank canvas. Browser/app cards aren't tracked
// here: their guest page owns its own scroll/zoom (Maps, Figma), so plain wheel always stays in them.
let scrollFocusedCardId: string | null = null;
export function setScrollFocusedCard(id: string | null): void {
scrollFocusedCardId = id;
}
export function getScrollFocusedCard(): string | null {
return scrollFocusedCardId;
}
+75 -41
View File
@@ -1,6 +1,7 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import { normalizeSessionName } from './sessionDisplay';
import { mergeSessionMessages } from './mergeSessionMessages';
const AGENTS_API = `${API_BASE}/agents`;
@@ -84,6 +85,12 @@ export interface AgentSession {
cost_usd: number;
tokens: { input: number; output: number };
messages: AgentMessage[];
/** Compact dashboard-list metadata; full messages are fetched when a chat opens. */
last_message_preview?: string;
first_user_message?: string;
message_count?: number;
/** WS seq high-water at snapshot time (GET /sessions only); seeds the resume cursor so connect skips replaying what REST just delivered. */
event_seq?: number;
pending_approvals: ApprovalRequest[];
branches: Record<string, MessageBranch>;
active_branch_id: string;
@@ -344,25 +351,47 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const launchData = await launchRes.json();
const session = launchData.session as AgentSession;
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload, { dispatch }) => {
// Optimistic bubble on the DRAFT before the three round-trips (launch/message/refetch): without it the first message of every fresh chat rendered nothing until the network came back. The fulfilled rekey swaps in the server session, which carries the real turn by then.
const clientMessageId = _genOptimisticId();
dispatch(addOptimisticMessage({
sessionId: draftId,
clientMessageId,
prompt,
contextPaths,
forcedTools,
attachedSkills: attachedSkills?.map((s) => ({ id: s.id, name: s.name })),
images: images?.map((img) => ({ data: img.data, media_type: img.media_type })),
hidden: false,
}));
try {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const launchData = await launchRes.json();
const session = launchData.session as AgentSession;
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds }),
});
// Only the launch response is load-bearing (it mints the session id); the message POST runs off the critical path so the rekey (and the chat's stream hookup) doesn't wait a round trip. The optimistic bubble already shows the message and flips to failed if this dies.
fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }),
}).then((res) => {
if (!res.ok) throw new Error(`first message failed: ${res.status}`);
}).catch(() => {
// The bubble lives on whichever session the rekey race left it in; one of these no-ops.
dispatch(markOptimisticFailed({ sessionId: session.id, clientMessageId }));
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
dispatch(updateSessionStatus({ sessionId: session.id, status: 'completed' }));
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
const updatedSession = await refreshRes.json() as AgentSession;
return { draftId, session: updatedSession };
return { draftId, session };
} catch (err) {
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
throw err;
}
}
);
@@ -505,7 +534,8 @@ export const deleteSession = createAsyncThunk(
export const fetchHistory = createAsyncThunk(
'agents/fetchHistory',
async ({ dashboardId }: { dashboardId?: string } = {}) => {
const params = new URLSearchParams({ limit: '10000' });
// closed_only: an OPEN session landing in state.history made updateSession's resurrection gate swallow its terminal frames (card stuck running, final answer invisible). Search (searchHistory) keeps the full pool.
const params = new URLSearchParams({ limit: '10000', closed_only: '1' });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${AGENTS_API}/history?${params}`);
const data = await res.json();
@@ -692,7 +722,8 @@ const agentsSlice = createSlice({
if (state.history[action.payload.id]) {
if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') {
delete state.history[action.payload.id];
} else {
} else if (!state.sessions[action.payload.id]) {
// Gate only truly-closed sessions (no live card): a late frame must not resurrect them. A LIVE session that leaked into history used to have its completed frame swallowed here, leaving the card stuck running.
return;
}
}
@@ -709,6 +740,9 @@ const agentsSlice = createSlice({
state.sessions[action.payload.id] = {
...action.payload,
name: normalizeSessionName(action.payload.name),
// Status frames replay stale on WS reconnect; the transcript and branch set only move forward here (fetchSession owns server-side deletes).
messages: mergeSessionMessages(existing?.messages, action.payload.messages, false),
branches: { ...existing?.branches, ...action.payload.branches },
pending_approvals: mergedApprovals,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
};
@@ -1193,8 +1227,21 @@ const agentsSlice = createSlice({
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const shouldExpand = action.meta.arg.expand !== false;
// The swap uses the LAUNCH response (no refetch round trip), so the user's message exists only as the draft's optimistic bubble; carry it (never the seeded greeting, which is cosmetic and must not reach the server session) plus anything the WS already landed under the server id.
const carried = [
...(state.sessions[session.id]?.messages ?? []),
...(state.sessions[draftId]?.messages ?? []).filter((m) => m.optimistic_status),
];
delete state.sessions[draftId];
state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] };
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
// The first message POST is in flight; its failure path flips this back (same optimism as sendMessage.pending).
status: 'running',
messages: mergeSessionMessages(carried, session.messages, false),
tool_group_meta: session.tool_group_meta ?? {},
pending_approvals: session.pending_approvals ?? [],
};
state.activeSessionId = session.id;
state.draftLaunchMap[draftId] = session.id;
state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id));
@@ -1362,35 +1409,18 @@ const agentsSlice = createSlice({
const session = action.payload;
const existing = state.sessions[session.id];
// Preserve local messages the server snapshot doesn't carry yet. On remount mid-stream (leave the chat + come back) this fetch's snapshot predates the just-sent user turn, so a blind replace wiped the user's own bubble while the assistant stream (separate slice) kept going. The WS echo clears optimistic_status the instant it arrives, so the message is usually "confirmed but not yet server-persisted" rather than still 'pending' (that's why a pending-only filter missed it). Gate on the session being LIVE: on a running/streaming session, carry forward any local message the snapshot lacks; on a settled session the snapshot is authoritative (so a server-side delete isn't resurrected).
const incomingMsgs = session.messages ?? [];
// Live by EITHER side's account: a send on a completed chat flips local status to running while the racing snapshot still says completed and lacks the new turn; trusting only the snapshot wiped the user bubble until the run finished.
const isLive = (s?: string) => s === 'running' || s === 'waiting_approval';
// A streaming session counts as live even if neither status says 'running' (streaming lives in streamingSlice). Without this, a mid-stream reopen dropped the just-sent user bubble until the turn finished.
const streamingActive = !!(session as AgentSession & { _streamingActive?: boolean })._streamingActive;
const liveStatus = streamingActive || isLive(session.status) || isLive(existing?.status);
const incomingClientIds = new Set(
incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
);
const incomingIds = new Set(incomingMsgs.map((m) => m.id));
// An optimistic message (no WS echo yet) is preserved even when both sides read settled: right after a send on a completed chat, NEITHER status has flipped to running, and the racing snapshot wiped the just-typed bubble for seconds. It can't be a deleted-message resurrection; the server has never confirmed it existed.
const surviving = (existing?.messages ?? []).filter(
(m) =>
(liveStatus || m.optimistic_status) &&
!incomingIds.has(m.id) &&
!(m.client_message_id && incomingClientIds.has(m.client_message_id)),
);
// Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT. Insert before the first incoming message that is newer.
const mergedMessages = surviving.length ? [...incomingMsgs] : incomingMsgs;
for (const m of surviving) {
const at = mergedMessages.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
if (at === -1) mergedMessages.push(m);
else mergedMessages.splice(at, 0, m);
}
delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive;
// Deletes only apply on a settled session: a snapshot racing a live turn is stale, not authoritative.
const stableMessages = mergeSessionMessages(existing?.messages, session.messages, !liveStatus);
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
messages: mergedMessages,
messages: stableMessages,
pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [],
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
// mcp_suggestions live in client state only (the backend never returns them in the session payload). Preserve them across refresh so the suggestion banner stays put until the user dismisses it or activates one.
@@ -1414,13 +1444,17 @@ const agentsSlice = createSlice({
})
.addCase(fetchBrowserAgentChildren.fulfilled, (state, action) => {
for (const session of action.payload) {
if (!state.sessions[session.id]) {
const existing = state.sessions[session.id];
if (!existing) {
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
tool_group_meta: session.tool_group_meta ?? {},
pending_approvals: session.pending_approvals ?? [],
};
} else if (existing.messages.length === 0 && session.messages.length > 0) {
// Hydrate a child the trimmed session-list poll left message-less; don't touch one mid-stream (already has messages).
existing.messages = session.messages;
}
}
})
@@ -392,8 +392,18 @@ export function findOpenSpotNear(
};
}
// Spiral by ring perimeter; right/down preference for stability.
// Ring order approximates distance but returns the first-in-scan cell, which flings a card to a
// far corner when the near cells are blocked (a big browser + expanded chats). Instead pick the
// cell CLOSEST to the anchor by real distance: scan outward, and once a ring yields a free cell,
// scan ONE more ring (a ring-r corner ~r*1.41 can lose to a ring-(r+1) edge) then take the nearest.
const MAX_RING = 32;
const spotDist = (col: number, row: number): number => {
const x = GRID_ORIGIN.x + col * cellW;
const y = GRID_ORIGIN.y + row * cellH;
return Math.hypot(x - anchorX, y - anchorY);
};
let best: { col: number; row: number; d: number } | null = null;
let firstHitRing = -1;
for (let r = 1; r <= MAX_RING; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
@@ -401,14 +411,20 @@ export function findOpenSpotNear(
const col = baseCol + dx;
const row = baseRow + dy;
if (col < 0 || row < 0) continue;
if (cellFree(col, row)) {
return {
x: GRID_ORIGIN.x + col * cellW,
y: GRID_ORIGIN.y + row * cellH,
};
}
if (!cellFree(col, row)) continue;
const d = spotDist(col, row);
if (!best || d < best.d) best = { col, row, d };
}
}
if (best && firstHitRing === -1) firstHitRing = r;
// Scan one ring past the first hit (a ring-r corner can lose to a ring-(r+1) edge), then commit.
if (firstHitRing !== -1 && r >= firstHitRing + 1) break;
}
if (best) {
return {
x: GRID_ORIGIN.x + best.col * cellW,
y: GRID_ORIGIN.y + best.row * cellH,
};
}
// Pathological, full canvas occupied near anchor. Fall back to the global first-empty scan so we never return an overlap.
@@ -511,8 +527,14 @@ export function computeSpawnPosition(
return placeBesideCard(state, anchor.beside, newW, newH, expandedSessionIds);
}
if (anchor.viewportCenter) {
// Land dead-center, "in front of you", even if a card is already there. Overlap is intentional (new card sits on top via its higher zOrder); dodging to free space is exactly the "spawned off to the side" behavior we're removing.
return { x: anchor.viewportCenter.x - newW / 2, y: anchor.viewportCenter.y - newH / 2 };
// Closest open gap to the viewport center: dead-center-with-overlap stacked spawns invisibly on top of each other (two center spawns in a row = the second fully covers the first). The spiral stays center-biased so it still reads as "in front of you".
return findOpenSpotNear(
anchor.viewportCenter.x - newW / 2,
anchor.viewportCenter.y - newH / 2,
collectOccupiedRects(state, expandedSessionIds),
newW,
newH,
);
}
return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds), newW, newH);
}
@@ -0,0 +1,44 @@
import type { AgentMessage } from './agentsSlice';
/** Merge a server snapshot's message list over the store's, so a stale or partial snapshot can
* never wipe the transcript: WS status frames replay from seq 0 on every (re)connect (the
* launch-time zero-message frame included), and whichever socket lands last used to blind-replace
* newer local state, which is how first messages and edited histories vanished.
*
* allowDeletes: only the settled-session REST fetch may honor a server-side delete; WS frames and
* the draft rekey never drop a local message the snapshot lacks. Optimistic messages always survive. */
export function mergeSessionMessages(
existing: AgentMessage[] | undefined,
incoming: AgentMessage[] | undefined,
allowDeletes: boolean,
): AgentMessage[] {
const incomingMsgs = incoming ?? [];
const existingMsgs = existing ?? [];
const incomingIds = new Set(incomingMsgs.map((m) => m.id));
const incomingClientIds = new Set(
incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
);
const surviving = existingMsgs.filter(
(m) =>
(!allowDeletes || m.optimistic_status) &&
!incomingIds.has(m.id) &&
!(m.client_message_id && incomingClientIds.has(m.client_message_id)),
);
// Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT.
const merged = surviving.length ? [...incomingMsgs] : incomingMsgs;
for (const m of surviving) {
const at = merged.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
if (at === -1) merged.push(m);
else merged.splice(at, 0, m);
}
// Keep the EXISTING object for any message the snapshot didn't change: fresh JSON clones of identical messages break every bubble's React.memo (a whole-transcript re-render hitch per frame).
const prevById = new Map(existingMsgs.map((m) => [m.id, m]));
const contentUnchanged = (a: AgentMessage, b: AgentMessage): boolean =>
typeof a.content === 'string' && typeof b.content === 'string'
? a.content === b.content
: Array.isArray(a.content) && Array.isArray(b.content) && a.content.length === b.content.length;
return merged.map((m) => {
const prev = prevById.get(m.id);
return prev && prev.timestamp === m.timestamp && prev.role === m.role && contentUnchanged(prev, m) ? prev : m;
});
}
+7 -2
View File
@@ -36,8 +36,13 @@ export function displayChatTitle(session: AgentSession | null | undefined): stri
return session.name;
}
const firstUserMsg = session.messages?.find((m) => m.role === 'user');
if (firstUserMsg && typeof firstUserMsg.content === 'string') {
const truncated = truncateForTitle(firstUserMsg.content);
const firstUserContent = firstUserMsg && typeof firstUserMsg.content === 'string'
? firstUserMsg.content
: session.messages.length === 0
? session.first_user_message
: undefined;
if (firstUserContent) {
const truncated = truncateForTitle(firstUserContent);
if (truncated) return truncated;
}
return session.mode === 'view-builder' ? 'Untitled App' : SESSION_NAME_PLACEHOLDER;
@@ -69,6 +69,8 @@ export interface Workflow {
deleted_at?: string | null;
system_prompt: string | null;
use_synced_prompt: boolean;
/** Agents may run this workflow via the InvokeWorkflow tool (opt-in per workflow on the Actions page). */
exposed_as_tool?: boolean;
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
+14
View File
@@ -0,0 +1,14 @@
// Srcdoc app-card iframes keyed by card key. Mirror of viewWebviewRegistry for the outputs that render as an iframe instead of a <webview> (no serve URL): the dashboard's arrow-key handler needs a handle on the card's content to scroll it, and a srcdoc frame is same-origin, so no IPC is involved.
const registry = new Map<string, HTMLIFrameElement>();
export function registerViewFrame(cardKey: string, frame: HTMLIFrameElement): void {
registry.set(cardKey, frame);
}
export function unregisterViewFrame(cardKey: string): void {
registry.delete(cardKey);
}
export function getViewFrame(cardKey: string): HTMLIFrameElement | undefined {
return registry.get(cardKey);
}
@@ -1,6 +1,8 @@
// Live app-card preview webviews keyed by output id. The delete path looks a card's <webview> up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews.
export interface ViewWebview extends HTMLElement {
loadURL: (url: string) => Promise<void>;
// Optional: present on real Electron webviews, absent on any non-Electron stand-in, so callers must ?.() it.
executeJavaScript?: (code: string) => Promise<unknown>;
}
const registry = new Map<string, ViewWebview>();
+48 -1
View File
@@ -28,7 +28,7 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { fetchSettings } from '../state/settingsSlice';
import { displaySessionName } from '../state/sessionDisplay';
@@ -324,6 +324,20 @@ class WebSocketManager {
}
}
// Cross-socket dedupe: the backend fans every session frame out to BOTH the dashboard socket and the chat's own socket (same stamped seq), so an expanded chat parsed and reduced everything twice, and whichever copy landed second could be a replayed stale one. Time-windowed rather than a high-water mark so a deliberate later replay (gap recovery resets lastSeq to 0) is never starved.
if (typeof msg.seq === 'number' && session_id) {
const key = `${session_id}:${msg.seq}`;
const now = Date.now();
const seen = _recentFrameTimes.get(key);
if (seen !== undefined && now - seen < FRAME_DEDUPE_WINDOW_MS) return;
_recentFrameTimes.set(key, now);
if (_recentFrameTimes.size > 4000) {
for (const [k, t] of _recentFrameTimes) {
if (now - t >= FRAME_DEDUPE_WINDOW_MS) _recentFrameTimes.delete(k);
}
}
}
// ----- Connection-scoped frames (no business-logic side effects) -----
if (event === 'server:pong') {
@@ -351,6 +365,10 @@ class WebSocketManager {
// Reset lastSeq, the REST refetch is the new authoritative baseline; subsequent server events with seq numbers will re-establish the high-water mark. Also wipe the cross-mount persistent map so a remount during this gap window doesn't resurrect the stale value.
this.lastSeq = 0;
_sessionLastSeq.delete(session_id);
// The recovery replay re-delivers seqs possibly seen moments ago; drop them from the dedupe window so it's never starved.
for (const k of _recentFrameTimes.keys()) {
if (k.startsWith(`${session_id}:`)) _recentFrameTimes.delete(k);
}
}
return;
}
@@ -384,6 +402,17 @@ class WebSocketManager {
store.dispatch(trackAgentNotification(session_id));
}
// An AppAgent driving an app card announces itself only via this status event (no card_added like browsers), so light the app card here. Keyed by the parent chat like browser glows, so the same terminal fade below clears it.
const p_sess = data.session;
if (p_sess && p_sess.mode === 'browser-agent' && typeof p_sess.browser_id === 'string' && p_sess.browser_id.startsWith('app:')
&& (p_sess.status === 'running' || p_sess.status === 'waiting_approval')) {
store.dispatch(setGlowingBrowserCards({
browserIds: [p_sess.browser_id],
sessionId: p_sess.parent_session_id || p_sess.id,
label: 'Use App',
}));
}
// Fade this session's browser glows on the terminal transition HERE, not only in AgentChat's effect: a collapsed chat is unmounted at finish, and a never-faded glow pins the browser's renderer (exempt from suspend + the webview cap) forever.
const newStatus = data.status ?? data.session?.status;
const wasWorking = prevStatus === 'running' || prevStatus === 'waiting_approval';
@@ -791,6 +820,13 @@ class WebSocketManager {
}
break;
case 'dashboard:browser_card_evict':
// A wedged card the backend is tearing down BEFORE it spawns a recovery card. Remove it now (no fade, no Keep pill) so its <webview> unmounts and stops starving the renderer while the fresh card mounts.
if (data.browser_id) {
store.dispatch(removeBrowserCard(data.browser_id));
}
break;
case 'dashboard:browser_card_added':
if (data.browser_card) {
// Tag with origin dashboard so the card renders only on the dashboard that spawned it, without this, a browser spawned by an agent on dashboard A leaks into whatever dashboard the user is currently viewing (the global browserCards dict + unfiltered render).
@@ -929,6 +965,17 @@ export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { ski
// Per-session high-water mark for the resume protocol. Survives across AgentChat mounts/unmounts so reopening a chat doesn't re-trigger a full replay from the server's ring buffer. Why this exists: AgentChat uses `key={session.id}` on the embedded instance inside AgentCard, so every expand/collapse remounts the component, which constructs a fresh WebSocketManager. Without this persistent map, each fresh manager starts at last_seq=0 and asks the server for the entire buffered history. The server faithfully replays it, the client renders the typewriter animation again, and the user sees their completed chat "type itself out" on every reopen. Lifetime: tied to the JS module load, which means the page tab. Lost on full app reload (intentional, that should re-hydrate from REST). On backend restart the buffers are wiped anyway, so a stale lastSeq pointing past the buffer top falls into the "fresh client" path on the server (last_seq>0 but no buffer) which short-circuits to a no-op replay. Safe.
const _sessionLastSeq: Map<string, number> = new Map();
// (session_id:seq) -> arrival time; entries older than the window are prunable. Bounded by event rate x window, not session count.
const FRAME_DEDUPE_WINDOW_MS = 5_000;
const _recentFrameTimes: Map<string, number> = new Map();
/** Seed the resume cursor from a REST hydrate (GET /sessions returns event_seq), so the follow-up WS connect replays only what happened AFTER the snapshot instead of the whole ring buffer the client just received as JSON. Never lowers an existing high-water mark. */
export function seedSessionSeq(sessionId: string, seq: number): void {
if (typeof seq !== 'number' || seq <= 0) return;
const cur = _sessionLastSeq.get(sessionId) ?? 0;
if (seq > cur) _sessionLastSeq.set(sessionId, seq);
}
export function createSessionWs(sessionId: string): WebSocketManager {
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`, { sessionId });
}
+3 -2
View File
@@ -79,13 +79,14 @@ module.exports = (env, argv) => {
devServer: {
static: { directory: path.join(__dirname, 'public') },
compress: true,
port: 3000,
// Dev only: OPENSWARM_DEV_PORT / OPENSWARM_PORT let a second worktree run its own stack without colliding on 3000/8324 (electron reads the same var names).
port: Number(process.env.OPENSWARM_DEV_PORT) || 3000,
hot: true,
open: false,
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:8324',
target: `http://localhost:${process.env.OPENSWARM_PORT || 8324}`,
changeOrigin: true,
},
},
+6
View File
@@ -99,6 +99,12 @@ function Cleanup-All {
Write-Host "All services stopped." -ForegroundColor Green
}
# Opt-in dev telemetry: OPENSWARM_ANALYTICS=1 reports to the prod edge tagged with a -dev channel so it stays filterable from real installs; off by default so a stray run never phones home.
if ($env:OPENSWARM_ANALYTICS -eq '1') {
if (-not $env:OPENSWARM_ANALYTICS_URL) { $env:OPENSWARM_ANALYTICS_URL = 'https://analytics.openswarm.com' }
if (-not $env:OPENSWARM_APP_CHANNEL) { $env:OPENSWARM_APP_CHANNEL = 'dev' }
}
try {
# --- Start backend (NoNewWindow so logs interleave into this terminal) ---
# No --reload on Windows: uvicorn's reload mode forces use_subprocess=True
+24 -9
View File
@@ -22,6 +22,14 @@ FRONTEND_PID=""
ELECTRON_PID=""
SHUTTING_DOWN=false
# Dev ports. Override BOTH to run a second worktree in parallel without colliding on 3000/8324
# (e.g. OPENSWARM_PORT=8425 OPENSWARM_DEV_PORT=3005 bash run.sh). Electron, backend/run.sh, and
# webpack all read these same names, so one export each wires the whole stack.
BACKEND_PORT="${OPENSWARM_PORT:-8324}"
FRONTEND_PORT="${OPENSWARM_DEV_PORT:-3000}"
export OPENSWARM_PORT="$BACKEND_PORT"
export OPENSWARM_DEV_PORT="$FRONTEND_PORT"
kill_tree() {
local pid=$1 sig=${2:-TERM}
local children
@@ -91,9 +99,9 @@ fi
# That makes the next `bash run.sh` fail with Errno 48 "Address already
# in use" and leaves the user thinking the dev loop is broken. Free the
# port up front instead of asking the user to debug.
if lsof -ti :8324 >/dev/null 2>&1; then
echo -e "${YELLOW}${BOLD}[preflight]${RESET} Port 8324 still bound from a prior run killing stale process..."
lsof -ti :8324 | xargs kill -9 2>/dev/null || true
if lsof -ti :$BACKEND_PORT >/dev/null 2>&1; then
echo -e "${YELLOW}${BOLD}[preflight]${RESET} Port ${BACKEND_PORT} still bound from a prior run, killing stale process..."
lsof -ti :$BACKEND_PORT | xargs kill -9 2>/dev/null || true
sleep 0.3
fi
@@ -103,6 +111,13 @@ fi
# directly), so the env stays unset in production and uvicorn boots in
# its leaner non-reload mode.
export OPENSWARM_DEV=1
# Opt-in dev telemetry. Dev normally never reports (the analytics client falls back to a dead local ingest); set OPENSWARM_ANALYTICS=1 (e.g. a hackathon cohort) to report to the prod edge, tagged with a -dev channel so those events stay filterable from real installs. Off by default so a stray `bash run.sh` never phones home.
if [ "${OPENSWARM_ANALYTICS:-0}" = "1" ]; then
export OPENSWARM_ANALYTICS_URL="${OPENSWARM_ANALYTICS_URL:-https://analytics.openswarm.com}"
export OPENSWARM_APP_CHANNEL="${OPENSWARM_APP_CHANNEL:-dev}"
fi
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
bash "$PROJECT_ROOT/backend/run.sh" > >(
while IFS= read -r line; do
@@ -112,11 +127,11 @@ bash "$PROJECT_ROOT/backend/run.sh" > >(
BACKEND_PID=$!
# --- Wait for backend to become healthy ---
echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:8324) to be ready...${RESET}"
echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:${BACKEND_PORT}) to be ready...${RESET}"
MAX_WAIT=120
elapsed=0
while (( elapsed < MAX_WAIT )); do
if curl -s -o /dev/null --connect-timeout 1 http://localhost:8324/ 2>/dev/null; then
if curl -s -o /dev/null --connect-timeout 1 http://localhost:${BACKEND_PORT}/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Backend is ready!${RESET}"
break
fi
@@ -143,11 +158,11 @@ bash "$PROJECT_ROOT/frontend/run.sh" > >(
FRONTEND_PID=$!
# --- Wait for frontend dev server to become available ---
echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:3000) to be ready...${RESET}"
echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:${FRONTEND_PORT}) to be ready...${RESET}"
FRONTEND_MAX_WAIT=60
frontend_elapsed=0
while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do
if curl -s -o /dev/null --connect-timeout 1 http://localhost:3000/ 2>/dev/null; then
if curl -s -o /dev/null --connect-timeout 1 http://localhost:${FRONTEND_PORT}/ 2>/dev/null; then
echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}"
break
fi
@@ -192,8 +207,8 @@ ELECTRON_PID=$!
echo ""
echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}"
echo -e " Backend: ${BLUE}http://localhost:8324${RESET}"
echo -e " Frontend: ${GREEN}http://localhost:3000${RESET}"
echo -e " Backend: ${BLUE}http://localhost:${BACKEND_PORT}${RESET}"
echo -e " Frontend: ${GREEN}http://localhost:${FRONTEND_PORT}${RESET}"
echo -e " Electron: ${MAGENTA}dev shell (pid $ELECTRON_PID)${RESET}"
echo ""