Merge branch 'eric/dev' of https://github.com/openswarm-ai/openswarm into eric/dev

This commit is contained in:
ciregenz
2026-07-15 17:36:42 -07:00
46 changed files with 1115 additions and 222 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] = {}
+32 -10
View File
@@ -1,15 +1,16 @@
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.manager.session.history_compaction import estimate_post_compact_input
from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
@@ -39,10 +40,31 @@ 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():
@@ -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
@@ -60,6 +60,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":
@@ -19,7 +19,6 @@ FULL_TOOLS = [
"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",
]
@@ -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"
@@ -188,7 +188,7 @@ 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
@@ -229,6 +229,9 @@ class RunOptions(AgentManagerProtocol):
# 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.
options_kwargs["disallowed_tools"] = [
"mcp__claude_ai_*",
# The CLI's built-in sub-agent tool is replaced by our SpawnAgent MCP (prompt + run_in_background only); it is named Task on CLI 2.1.122 (Agent on older builds, kept for safety), and its subagent types resolve to models router setups can't serve.
"Agent",
"Task",
]
if session.cwd:
@@ -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"},
@@ -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()
+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")
+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.
+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"] == ""
+109
View File
@@ -0,0 +1,109 @@
"""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
assert "Agent" not in FULL_TOOLS
assert "Task" not in FULL_TOOLS
import inspect
from backend.apps.agents.manager.run import RunOptions
src = inspect.getsource(RunOptions)
block = src.split('disallowed_tools"] = [')[1][:400]
assert '"Agent",' in block and '"Task",' in block
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:
proc = subprocess.Popen(
[sys.executable, "backend/apps/agents/spawn_agent_mcp_server.py"],
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
@@ -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();
@@ -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();
@@ -339,15 +339,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(() => {
@@ -653,7 +658,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];
@@ -404,6 +404,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]) {
@@ -428,6 +435,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);
@@ -441,6 +449,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; }
});
@@ -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.
@@ -9,6 +9,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 {
@@ -173,7 +179,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
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
@@ -199,9 +205,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
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.
return {
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio,
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy,
zoom: newZoom,
};
});
@@ -230,8 +237,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 +263,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
scrollableCache.set(target, cls);
}
if (cls === 'scrollable' && !isPinchZoom) {
if (cls === 'scrollable' && !isModifierWheel) {
// 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 +296,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();
}
};
@@ -651,7 +667,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;
@@ -663,7 +679,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
Math.abs(cur2.panY - fresh.panY) +
Math.abs(cur2.zoom - fresh.zoom) * 1000;
if (drift > 8) setState(fresh);
}, 370);
}, FIT_SETTLE_DELAY);
} else {
setState(target);
}
@@ -671,6 +687,46 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
[cancelAnimation, animateTo, computeFitTarget],
);
// 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(() => ({
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
@@ -678,8 +734,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
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,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation]);
return {
...state,
@@ -81,6 +81,8 @@ export function useCardDrag({
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
activeDragCardRef.current = id;
// Reuse the marquee's webview shield: a pointerup released over a live <webview> is eaten by the guest process, so neither the card's onDragEnd nor the window backstop ever fires and the edge-pan loop pans forever (the "card drifts on its own" bug).
document.body.classList.add('dashboard-marquee-active');
if (selection.isSelected(id)) {
isMultiDragRef.current = true;
} else {
@@ -108,6 +110,7 @@ export function useCardDrag({
const clearDrag = useCallback(() => {
stopEdgePan();
activeDragCardRef.current = null;
document.body.classList.remove('dashboard-marquee-active');
isMultiDragRef.current = false;
setMultiDragDelta(null);
setLiveDragInfo(null);
@@ -1,6 +1,7 @@
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 type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
@@ -72,6 +73,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(() => {
@@ -94,6 +97,16 @@ 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' }));
};
@@ -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);
@@ -215,7 +215,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);
@@ -231,13 +231,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);
@@ -253,7 +247,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);
@@ -268,11 +262,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);
@@ -352,7 +342,7 @@ export function useDashboardLifecycle({
const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }];
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);
}
@@ -366,7 +356,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;
@@ -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' }}
@@ -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';
@@ -58,7 +54,6 @@ const WorkflowsAppCard: React.FC<Props> = ({
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const panRef = useRef({ panX, panY });
@@ -216,31 +211,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;
+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;
}
}
+53 -18
View File
@@ -84,6 +84,10 @@ 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;
pending_approvals: ApprovalRequest[];
branches: Record<string, MessageBranch>;
active_branch_id: string;
@@ -342,25 +346,42 @@ 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 }),
});
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, client_message_id: clientMessageId }),
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
const updatedSession = await refreshRes.json() as AgentSession;
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
const updatedSession = await refreshRes.json() as AgentSession;
return { draftId, session: updatedSession };
return { draftId, session: updatedSession };
} catch (err) {
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
throw err;
}
}
);
@@ -1385,10 +1406,20 @@ const agentsSlice = createSlice({
else mergedMessages.splice(at, 0, m);
}
delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive;
// Keep the EXISTING object for any message the snapshot didn't change: this refetch runs every 5s while a session is live, and fresh JSON clones of identical messages broke every bubble's React.memo (a whole-transcript re-render hitch per tick).
const prevById = new Map((existing?.messages ?? []).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;
const stableMessages = mergedMessages.map((m) => {
const prev = prevById.get(m.id);
return prev && prev.timestamp === m.timestamp && prev.role === m.role && contentUnchanged(prev, m) ? prev : m;
});
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.
@@ -1412,13 +1443,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;
}
}
})
@@ -505,8 +505,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);
}
+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;
+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>();