Merge haik/updates-v1 — resolve settings model conflict

Keep both telephony credentials and dashboard UI preferences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-20 16:44:40 -07:00
co-authored by Claude Opus 4.6
69 changed files with 6582 additions and 885 deletions
+2
View File
@@ -8,7 +8,9 @@ backend/data/**
# Electron build artifacts
electron/dist/
electron/python-env/
electron/build-staging/
electron/node_modules/
electron/package-lock.json
# Frontend build output
frontend/dist/
+512 -96
View File
@@ -66,6 +66,8 @@ FULL_TOOLS = [
"TaskOutput", "TaskStop",
"CronCreate", "CronList", "CronDelete",
"RenderOutput",
"InvokeAgent",
"Agent",
]
def _get_denied_tool_names(tool) -> set[str]:
@@ -232,8 +234,12 @@ class AgentManager:
+ "\n</available_views>"
)
def _build_browser_context(self, dashboard_id: str | None) -> str | None:
"""Build a context block listing browser cards and delegation instructions."""
def _build_browser_context(self, dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None:
"""Build a context block listing browser cards and delegation instructions.
Only browser cards explicitly selected by the user are included.
If none are selected, no browser card details are exposed.
"""
if not dashboard_id:
return None
try:
@@ -246,34 +252,39 @@ class AgentManager:
lines = [
"<browser_agent_instructions>",
"You have access to browser automation through the BrowserAgent and BrowserAgents tools.",
"You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.",
"",
"- **BrowserAgent(task, browser_id?, url?)**: Delegate a single browser task to a dedicated browser agent. "
"- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. "
"Use this when you need a fresh browser. Optionally provide a starting URL.",
"- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. "
"The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.",
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel, each on a different browser.",
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. "
"Each task requires a browser_id.",
"",
"You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). "
"Instead, describe what you want accomplished and the browser agent will handle the details.",
"",
"If you omit browser_id, a new browser card will be auto-created. "
"If you provide a url without a browser_id, the new browser navigates there first.",
]
if browser_cards:
lines.append("")
lines.append("Available browser cards on the dashboard:")
for card in browser_cards.values():
bid = card.get("browser_id", "")
tabs = card.get("tabs", [])
active_tab_id = card.get("activeTabId", "")
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
url = (active_tab or {}).get("url", card.get("url", ""))
title = (active_tab or {}).get("title", "")
lines.append(f"- browser_id: \"{bid}\"")
if title:
lines.append(f" Title: {title}")
if url:
lines.append(f" URL: {url}")
if browser_cards and selected_browser_ids:
visible_cards = [
card for card in browser_cards.values()
if card.get("browser_id", "") in selected_browser_ids
]
if visible_cards:
lines.append("")
lines.append("The user selected these browser cards for you to work with:")
for card in visible_cards:
bid = card.get("browser_id", "")
tabs = card.get("tabs", [])
active_tab_id = card.get("activeTabId", "")
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
url = (active_tab or {}).get("url", card.get("url", ""))
title = (active_tab or {}).get("title", "")
lines.append(f"- browser_id: \"{bid}\"")
if title:
lines.append(f" Title: {title}")
if url:
lines.append(f" URL: {url}")
lines.append("</browser_agent_instructions>")
return "\n".join(lines)
@@ -456,7 +467,7 @@ class AgentManager:
})
return content
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None):
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None):
"""Run the Claude Agent SDK query loop for a session."""
session = self.sessions.get(session_id)
if not session:
@@ -481,58 +492,53 @@ class AgentManager:
_builtin_perms = load_builtin_permissions()
def _check_tool_permission(tool_name: str) -> str | None:
"""Check tool permissions for both builtin and MCP tools.
Returns 'always_allow', 'deny', or None (ask)."""
def _get_effective_policy(tool_name: str) -> str:
"""Return 'always_allow', 'deny', or 'ask' for any tool."""
if tool_name in _builtin_perms:
policy = _builtin_perms[tool_name]
if policy in ("always_allow", "deny"):
return policy
return None
return _builtin_perms[tool_name]
import re as _re
bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name)
if bm:
return _builtin_perms.get(bm.group(1), "always_allow")
im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name)
if im:
return _builtin_perms.get(im.group(1), "always_allow")
m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
if not m:
return None
server_slug, mcp_tool_name = m.group(1), m.group(2)
for t in load_all_tools():
if not t.mcp_config or not t.enabled:
continue
if _sanitize_server_name(t.name) == server_slug:
policy = t.tool_permissions.get(mcp_tool_name, "ask")
if policy in ("always_allow", "deny"):
return policy
return None
return None
async def can_use_tool(tool_name, input_data, context):
if tool_name != "AskUserQuestion":
policy = _check_tool_permission(tool_name)
if policy == "always_allow":
return PermissionResultAllow(updated_input=input_data)
if policy == "deny":
return PermissionResultDeny(message="Tool denied by permission policy")
if m:
server_slug, mcp_tool_name = m.group(1), m.group(2)
for t in load_all_tools():
if not t.mcp_config or not t.enabled:
continue
if _sanitize_server_name(t.name) == server_slug:
return t.tool_permissions.get(mcp_tool_name, "ask")
return "always_allow"
async def _request_user_approval(tool_name: str, tool_input) -> dict:
"""Send an approval request via WebSocket and wait for the user's decision."""
safe_input = tool_input if isinstance(tool_input, dict) else {}
request_id = uuid4().hex
approval_req = ApprovalRequest(
id=request_id,
session_id=session_id,
tool_name=tool_name,
tool_input=input_data if isinstance(input_data, dict) else {},
tool_input=safe_input,
)
session.pending_approvals.append(approval_req)
session.status = "waiting_approval"
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "waiting_approval",
})
decision = await ws_manager.send_approval_request(
session_id, request_id, tool_name,
input_data if isinstance(input_data, dict) else {}
session_id, request_id, tool_name, safe_input
)
session.pending_approvals = [
a for a in session.pending_approvals if a.id != request_id
]
@@ -541,22 +547,67 @@ class AgentManager:
"session_id": session_id,
"status": "running",
})
return decision
async def can_use_tool(tool_name, input_data, context):
if tool_name != "AskUserQuestion":
policy = _get_effective_policy(tool_name)
if policy == "always_allow":
return PermissionResultAllow(updated_input=input_data)
if policy == "deny":
return PermissionResultDeny(message="Tool denied by permission policy")
decision = await _request_user_approval(tool_name, input_data)
if decision.get("behavior") == "allow":
return PermissionResultAllow(
updated_input=decision.get("updated_input", input_data)
)
else:
return PermissionResultDeny(
message=decision.get("message", "User denied this action")
)
return PermissionResultDeny(
message=decision.get("message", "User denied this action")
)
tool_start_times: dict[str, float] = {}
async def pre_tool_hook(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name", "")
hook_event = input_data.get("hook_event_name", "PreToolUse")
if tool_name and tool_name != "AskUserQuestion":
policy = _get_effective_policy(tool_name)
if policy == "deny":
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny",
"permissionDecisionReason": "Tool denied by permission policy",
}
}
if policy == "ask":
tool_input = input_data.get("tool_input", {})
decision = await _request_user_approval(tool_name, tool_input)
if decision.get("behavior") == "allow":
if tool_use_id:
tool_start_times[tool_use_id] = time.time()
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "allow",
}
}
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny",
"permissionDecisionReason": decision.get("message", "User denied this action"),
}
}
if tool_use_id:
tool_start_times[tool_use_id] = time.time()
return {"continue_": True}
return {}
async def post_tool_hook(input_data, tool_use_id, context):
elapsed_ms = None
@@ -590,7 +641,61 @@ class AgentManager:
if elapsed_ms is not None:
result_payload["elapsed_ms"] = elapsed_ms
result_msg = Message(role="tool_result", content=result_payload)
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)
sub_tokens["output"] = usage.get("output_tokens", 0)
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"
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,
)
self.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
result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -602,36 +707,95 @@ class AgentManager:
_, mode_sys_prompt, _ = self._resolve_mode(session.mode)
connected_tools_ctx = self._build_connected_tools_context(session.allowed_tools)
outputs_ctx = self._build_outputs_context()
browser_ctx = self._build_browser_context(session.dashboard_id)
browser_ctx = self._build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids)
global_settings = load_settings()
composed_prompt = self._compose_system_prompt(global_settings.default_system_prompt, mode_sys_prompt, session.system_prompt, connected_tools_ctx, outputs_ctx, browser_ctx)
if session.mode == "view-builder":
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL
skill_block = f"<app_builder_reference>\n{VIEW_BUILDER_SKILL}\n</app_builder_reference>"
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
browser_agent_server_path = os.path.join(
os.path.dirname(__file__), "browser_agent_mcp_server.py"
_browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"]
_browser_all_denied = all(
_builtin_perms.get(t, "always_allow") == "deny"
for t in _browser_delegation_tools
)
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
pre_selected_bids = self._get_pre_selected_browser_ids(session.dashboard_id)
mcp_servers["openswarm-browser-agent"] = {
"command": sys.executable,
"args": [browser_agent_server_path],
"env": {
"OPENSWARM_PORT": backend_port,
"OPENSWARM_AGENT_MODEL": session.model,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
"OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids),
},
"type": "stdio",
}
if not _browser_all_denied:
browser_agent_server_path = os.path.join(
os.path.dirname(__file__), "browser_agent_mcp_server.py"
)
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
pre_selected_bids = self._get_pre_selected_browser_ids(session.dashboard_id)
mcp_servers["openswarm-browser-agent"] = {
"command": sys.executable,
"args": [browser_agent_server_path],
"env": {
"OPENSWARM_PORT": backend_port,
"OPENSWARM_AGENT_MODEL": session.model,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
"OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids),
"OPENSWARM_PARENT_SESSION_ID": session.id,
},
"type": "stdio",
}
_invoke_agent_tools = ["InvokeAgent"]
_invoke_all_denied = all(
_builtin_perms.get(t, "always_allow") == "deny"
for t in _invoke_agent_tools
)
if not _invoke_all_denied:
invoke_agent_server_path = os.path.join(
os.path.dirname(__file__), "invoke_agent_mcp_server.py"
)
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
mcp_servers["openswarm-invoke-agent"] = {
"command": sys.executable,
"args": [invoke_agent_server_path],
"env": {
"OPENSWARM_PORT": backend_port,
"OPENSWARM_PARENT_SESSION_ID": session.id,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
},
"type": "stdio",
}
effective_allowed = [
t for t in session.allowed_tools
if _builtin_perms.get(t, "always_allow") == "always_allow"
if t in FULL_TOOLS and _builtin_perms.get(t, "always_allow") == "always_allow"
]
effective_disallowed = [
t for t in FULL_TOOLS
if _builtin_perms.get(t, "always_allow") == "deny"
]
if mcp_servers:
all_tools_list = load_all_tools()
for name in mcp_servers:
if name == "openswarm-browser-agent":
for bt in _browser_delegation_tools:
policy = _builtin_perms.get(bt, "always_allow")
if policy == "always_allow":
effective_allowed.append(f"mcp__openswarm-browser-agent__{bt}")
elif policy == "deny":
effective_disallowed.append(f"mcp__openswarm-browser-agent__{bt}")
continue
if name == "openswarm-invoke-agent":
for it in _invoke_agent_tools:
policy = _builtin_perms.get(it, "always_allow")
if policy == "always_allow":
effective_allowed.append(f"mcp__openswarm-invoke-agent__{it}")
elif policy == "deny":
effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}")
continue
tool_def = next(
(t for t in all_tools_list
if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == name),
@@ -644,20 +808,22 @@ class AgentManager:
policy = tool_def.tool_permissions.get(tn, "ask")
if policy == "always_allow":
effective_allowed.append(f"mcp__{name}__{tn}")
for tn in denied:
effective_disallowed.append(f"mcp__{name}__{tn}")
else:
effective_allowed.append(f"mcp__{name}__*")
effective_allowed.append("mcp__openswarm-browser-agent__*")
options_kwargs = {
"model": session.model,
"max_buffer_size": 5 * 1024 * 1024,
"permission_mode": "default",
"can_use_tool": can_use_tool,
"hooks": {
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
"PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])],
},
"allowed_tools": effective_allowed,
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
if not global_settings.anthropic_api_key:
@@ -675,6 +841,8 @@ class AgentManager:
if session.sdk_session_id:
options_kwargs["resume"] = session.sdk_session_id
if fork_session:
options_kwargs["fork_session"] = True
options = ClaudeAgentOptions(**options_kwargs)
@@ -775,6 +943,7 @@ class AgentManager:
id=stream_text_msg_id or uuid4().hex,
role="assistant",
content="\n".join(content_parts),
branch_id=session.active_branch_id,
)
session.messages.append(asst_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -784,7 +953,7 @@ class AgentManager:
for i, tu in enumerate(tool_uses):
msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex
tool_msg = Message(id=msg_id, role="tool_call", content=tu)
tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id)
session.messages.append(tool_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -811,7 +980,7 @@ class AgentManager:
except Exception as e:
logger.exception(f"Agent {session_id} error: {e}")
session.status = "error"
error_msg = Message(role="system", content=f"Error: {str(e)}")
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -912,7 +1081,7 @@ class AgentManager:
session_id, tool_msg_id, "Bash",
_json.dumps(tool_input_content["input"], indent=2),
)
tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content)
tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content, branch_id=session.active_branch_id)
session.messages.append(tool_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -922,7 +1091,7 @@ class AgentManager:
await asyncio.sleep(1)
if decision.get("behavior") == "allow":
tool_result = Message(role="tool_result", content=f"Processing: {prompt}")
tool_result = Message(role="tool_result", content=f"Processing: {prompt}", branch_id=session.active_branch_id)
session.messages.append(tool_result)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -940,7 +1109,7 @@ class AgentManager:
asst_msg_id = uuid4().hex
await self._stream_text(session_id, asst_msg_id, asst_text)
asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text)
asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text, branch_id=session.active_branch_id)
session.messages.append(asst_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
@@ -969,6 +1138,8 @@ class AgentManager:
context_paths: list | None = None,
forced_tools: list[str] | None = None,
attached_skills: list | None = None,
hidden: bool = False,
selected_browser_ids: list[str] | None = None,
):
"""Send a follow-up message to an existing session."""
session = self.sessions.get(session_id)
@@ -1000,10 +1171,12 @@ class AgentManager:
user_msg = Message(
role="user",
content=prompt,
branch_id=session.active_branch_id,
context_paths=context_paths if context_paths else None,
attached_skills=skill_meta,
forced_tools=forced_tools if forced_tools else None,
images=image_meta,
hidden=hidden,
)
session.messages.append(user_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -1011,7 +1184,14 @@ class AgentManager:
"message": user_msg.model_dump(mode="json"),
})
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills))
session.status = "running"
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "running",
"session": session.model_dump(mode="json"),
})
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids))
self.tasks[session_id] = task
async def stop_agent(self, session_id: str):
@@ -1043,6 +1223,14 @@ class AgentManager:
if not session:
raise ValueError(f"Session {session_id} not found")
existing = self.tasks.get(session_id)
if existing and not existing.done():
existing.cancel()
try:
await existing
except asyncio.CancelledError:
pass
target_msg = None
for i, msg in enumerate(session.messages):
if msg.id == message_id:
@@ -1052,11 +1240,24 @@ class AgentManager:
if not target_msg or target_msg.role != "user":
raise ValueError("Can only edit user messages")
new_branch_id = uuid4().hex[:8]
fork_point_id = message_id
fork_parent_branch = target_msg.branch_id
msg_branch = session.branches.get(target_msg.branch_id)
if msg_branch and msg_branch.fork_point_message_id:
branch_user_msgs = [
m for m in session.messages
if m.branch_id == target_msg.branch_id and m.role == "user"
]
if branch_user_msgs and branch_user_msgs[0].id == message_id:
fork_point_id = msg_branch.fork_point_message_id
fork_parent_branch = msg_branch.parent_branch_id or "main"
new_branch_id = uuid4().hex
new_branch = MessageBranch(
id=new_branch_id,
parent_branch_id=target_msg.branch_id,
fork_point_message_id=message_id,
parent_branch_id=fork_parent_branch,
fork_point_message_id=fork_point_id,
)
session.branches[new_branch_id] = new_branch
session.active_branch_id = new_branch_id
@@ -1066,6 +1267,10 @@ class AgentManager:
content=new_content,
branch_id=new_branch_id,
parent_id=target_msg.parent_id,
images=target_msg.images,
context_paths=target_msg.context_paths,
forced_tools=target_msg.forced_tools,
attached_skills=target_msg.attached_skills,
)
session.messages.append(edited_msg)
@@ -1079,7 +1284,14 @@ class AgentManager:
"active_branch_id": new_branch_id,
})
task = asyncio.create_task(self._run_agent_loop(session_id, new_content))
session.sdk_session_id = None
task = asyncio.create_task(self._run_agent_loop(
session_id, new_content,
images=target_msg.images,
context_paths=target_msg.context_paths,
forced_tools=target_msg.forced_tools,
attached_skills=target_msg.attached_skills,
))
self.tasks[session_id] = task
async def switch_branch(self, session_id: str, branch_id: str):
@@ -1369,7 +1581,6 @@ class AgentManager:
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
session.pending_approvals = []
session.closed_at = session.closed_at or datetime.now()
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = self._build_search_text(session)
_save_session(session_id, doc_data)
@@ -1378,21 +1589,208 @@ class AgentManager:
self.tasks.clear()
async def restore_all_sessions(self) -> None:
"""On startup, reload all persisted sessions from JSON files back into memory."""
"""On startup, reload all persisted sessions from JSON files back into memory.
Only sessions without closed_at are restored (they were active at
shutdown). Sessions with closed_at were explicitly closed by the user
and stay on disk so the history endpoint can still serve them.
"""
for sid, data in _load_all_session_data():
try:
session = AgentSession(**data)
except Exception as e:
logger.warning(f"Skipping corrupt session file {sid}: {e}")
continue
if session.closed_at is not None:
continue
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
session.closed_at = None
session.pending_approvals = []
self.sessions[session.id] = session
_delete_session_file(sid)
logger.info(f"Restored session {session.id}")
async def duplicate_session(self, session_id: str, dashboard_id: str | None = None, up_to_message_id: str | None = None) -> AgentSession:
"""Create an independent copy of a session with the same chat history."""
source = self.sessions.get(session_id)
if not source:
data = _load_session_data(session_id)
if data is None:
raise ValueError(f"Session {session_id} not found")
source = AgentSession(**data)
source_messages = list(source.messages)
if up_to_message_id:
cut_idx = next(
(i for i, m in enumerate(source_messages) if m.id == up_to_message_id),
None,
)
if cut_idx is not None:
source_messages = source_messages[: cut_idx + 1]
old_to_new_msg: dict[str, str] = {}
new_messages: list[Message] = []
for msg in source_messages:
new_id = uuid4().hex
old_to_new_msg[msg.id] = new_id
new_messages.append(Message(
id=new_id,
role=msg.role,
content=msg.content,
timestamp=msg.timestamp,
branch_id=msg.branch_id,
parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None,
context_paths=msg.context_paths,
attached_skills=msg.attached_skills,
forced_tools=msg.forced_tools,
images=msg.images,
))
new_branches: dict[str, MessageBranch] = {}
for bid, branch in source.branches.items():
new_branches[bid] = MessageBranch(
id=bid,
parent_branch_id=branch.parent_branch_id,
fork_point_message_id=old_to_new_msg.get(branch.fork_point_message_id) if branch.fork_point_message_id else None,
created_at=branch.created_at,
)
new_session = AgentSession(
id=uuid4().hex,
name=f"{source.name} (copy)",
status="stopped",
model=source.model,
mode=source.mode,
system_prompt=source.system_prompt,
allowed_tools=list(source.allowed_tools),
max_turns=source.max_turns,
cwd=source.cwd,
created_at=datetime.now(),
messages=new_messages,
branches=new_branches,
active_branch_id=source.active_branch_id,
tool_group_meta=dict(source.tool_group_meta),
dashboard_id=dashboard_id or source.dashboard_id,
)
self.sessions[new_session.id] = new_session
await ws_manager.send_to_session(new_session.id, "agent:status", {
"session_id": new_session.id,
"status": new_session.status,
"session": new_session.model_dump(mode="json"),
})
return new_session
async def invoke_agent(
self,
source_session_id: str,
message: str,
parent_session_id: str | None = None,
dashboard_id: str | None = None,
) -> dict:
"""Fork an existing session and send it a new message, returning the result."""
source = self.sessions.get(source_session_id)
if not source:
data = _load_session_data(source_session_id)
if data is None:
raise ValueError(f"Session {source_session_id} not found")
source = AgentSession(**data)
source_name = source.name
old_to_new_msg: dict[str, str] = {}
new_messages: list[Message] = []
for msg in source.messages:
new_id = uuid4().hex
old_to_new_msg[msg.id] = new_id
new_messages.append(Message(
id=new_id,
role=msg.role,
content=msg.content,
timestamp=msg.timestamp,
branch_id=msg.branch_id,
parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None,
context_paths=msg.context_paths,
attached_skills=msg.attached_skills,
forced_tools=msg.forced_tools,
images=msg.images,
))
new_branches: dict[str, MessageBranch] = {}
for bid, branch in source.branches.items():
new_branches[bid] = MessageBranch(
id=bid,
parent_branch_id=branch.parent_branch_id,
fork_point_message_id=(
old_to_new_msg.get(branch.fork_point_message_id)
if branch.fork_point_message_id else None
),
created_at=branch.created_at,
)
fork = AgentSession(
id=uuid4().hex,
name=f"{source_name} (invoked)",
status="running",
model=source.model,
mode="invoked-agent",
sdk_session_id=source.sdk_session_id,
system_prompt=source.system_prompt,
allowed_tools=list(source.allowed_tools),
max_turns=source.max_turns or 25,
cwd=source.cwd,
created_at=datetime.now(),
messages=new_messages,
branches=new_branches,
active_branch_id=source.active_branch_id,
tool_group_meta=dict(source.tool_group_meta),
dashboard_id=dashboard_id or source.dashboard_id,
parent_session_id=parent_session_id,
)
self.sessions[fork.id] = fork
await ws_manager.broadcast_global("agent:status", {
"session_id": fork.id,
"status": fork.status,
"session": fork.model_dump(mode="json"),
})
user_msg = Message(
role="user",
content=message,
branch_id=fork.active_branch_id,
)
fork.messages.append(user_msg)
await ws_manager.send_to_session(fork.id, "agent:message", {
"session_id": fork.id,
"message": user_msg.model_dump(mode="json"),
})
await self._run_agent_loop(fork.id, message, fork_session=True)
last_assistant = None
for msg in reversed(fork.messages):
if msg.role == "assistant":
content = msg.content
if isinstance(content, str):
last_assistant = content
elif isinstance(content, list):
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
last_assistant = "\n".join(texts)
else:
last_assistant = str(content)
break
return {
"forked_session_id": fork.id,
"source_name": source_name,
"response": last_assistant or "No response from invoked agent.",
"cost_usd": fork.cost_usd,
}
def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]:
if dashboard_id:
return [s for s in self.sessions.values() if s.dashboard_id == dashboard_id]
@@ -1401,4 +1799,22 @@ class AgentManager:
def get_session(self, session_id: str) -> Optional[AgentSession]:
return self.sessions.get(session_id)
def get_browser_agent_children(self, parent_session_id: str) -> list[dict]:
"""Return browser-agent sessions for a parent, from memory or disk."""
results: list[dict] = []
seen: set[str] = set()
for s in self.sessions.values():
if s.mode == "browser-agent" and s.parent_session_id == parent_session_id:
results.append(s.model_dump(mode="json"))
seen.add(s.id)
for sid, data in _load_all_session_data():
if sid in seen:
continue
if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id:
results.append(data)
return results
agent_manager = AgentManager()
+19
View File
@@ -56,6 +56,8 @@ async def send_message(session_id: str, body: dict):
context_paths=body.get("context_paths"),
forced_tools=body.get("forced_tools"),
attached_skills=body.get("attached_skills"),
hidden=body.get("hidden", False),
selected_browser_ids=body.get("selected_browser_ids"),
)
return {"ok": True}
@@ -131,6 +133,18 @@ async def get_branches(session_id: str):
"active_branch_id": session.active_branch_id,
}
@agents.router.post("/sessions/{session_id}/duplicate")
async def duplicate_session(session_id: str, body: dict = {}):
try:
session = await agent_manager.duplicate_session(
session_id,
dashboard_id=body.get("dashboard_id"),
up_to_message_id=body.get("up_to_message_id"),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"session": session.model_dump(mode="json")}
@agents.router.post("/sessions/{session_id}/close")
async def close_session(session_id: str):
try:
@@ -151,6 +165,11 @@ async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_i
dashboard_id=dashboard_id or None,
)
@agents.router.get("/sessions/{session_id}/browser-agents")
async def get_browser_agent_children(session_id: str):
children = agent_manager.get_browser_agent_children(session_id)
return {"sessions": children}
@agents.router.post("/sessions/{session_id}/resume")
async def resume_session(session_id: str):
try:
+145 -6
View File
@@ -15,8 +15,9 @@ from uuid import uuid4
import anthropic
from backend.apps.agents.models import AgentSession, Message
from backend.apps.agents.models import AgentSession, ApprovalRequest, Message
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.tools_lib.tools_lib import load_builtin_permissions
logger = logging.getLogger(__name__)
@@ -112,6 +113,48 @@ BROWSER_TOOLS_SCHEMA = [
"required": [],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"input_schema": {
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": [],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"input_schema": {
"type": "object",
"properties": {
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": [],
},
},
]
ACTION_MAP = {
@@ -122,17 +165,29 @@ ACTION_MAP = {
"BrowserType": "type",
"BrowserEvaluate": "evaluate",
"BrowserGetElements": "get_elements",
"BrowserScroll": "scroll",
"BrowserWait": "wait",
}
SYSTEM_PROMPT = (
"You are a browser automation agent. You control a single browser tab and "
"execute the task you are given.\n\n"
"Strategy:\n"
"1. Start by taking a screenshot or calling BrowserGetElements to understand the page.\n"
"2. Use BrowserGetElements BEFORE clicking or typing to discover valid CSS selectors.\n"
"3. After performing actions, take a screenshot to verify the result.\n"
"4. If an action fails, try alternative selectors or approaches.\n"
"5. When the task is complete, provide a clear summary of what you accomplished.\n\n"
"1. Start by taking a screenshot to understand the page.\n"
"2. After navigation, use BrowserWait (1-3 seconds) to let the page finish loading.\n"
"3. Use BrowserScroll to scroll through pages — do NOT use BrowserEvaluate with "
"window.scrollBy() as many sites use nested scroll containers that BrowserScroll "
"handles automatically.\n"
"4. Use BrowserGetElements BEFORE clicking or typing to discover valid CSS selectors.\n"
"5. After performing actions, take a screenshot to verify the result.\n"
"6. If an action fails, try alternative selectors or approaches.\n"
"7. When the task is complete, provide a clear summary of what you accomplished.\n\n"
"Important notes:\n"
"- BrowserGetText returns up to 15000 chars of visible text — use it to read page content.\n"
"- BrowserScroll returns position info including atTop/atBottom — use this to know when "
"you've reached the end of the page.\n"
"- For complex SPAs (Notion, Gmail, etc.), prefer BrowserScroll over BrowserEvaluate for scrolling.\n"
"- Avoid looping: if scrolling shows no new content (scrolled 0px), you're at the boundary.\n\n"
"You have access ONLY to browser tools. Do not ask the user questions — "
"complete the task autonomously to the best of your ability."
)
@@ -179,6 +234,40 @@ def _format_tool_result(result: dict, tool_name: str) -> list[dict]:
return [{"type": "text", "text": str(text)}]
async def _request_browser_approval(
session: AgentSession, tool_name: str, tool_input: dict,
) -> dict:
"""Send an approval request for a browser sub-agent tool and wait for the decision."""
request_id = uuid4().hex
approval_req = ApprovalRequest(
id=request_id,
session_id=session.id,
tool_name=tool_name,
tool_input=tool_input,
)
session.pending_approvals.append(approval_req)
session.status = "waiting_approval"
await ws_manager.send_to_session(session.id, "agent:status", {
"session_id": session.id,
"status": "waiting_approval",
})
decision = await ws_manager.send_approval_request(
session.id, request_id, tool_name, tool_input,
)
session.pending_approvals = [
a for a in session.pending_approvals if a.id != request_id
]
session.status = "running"
await ws_manager.send_to_session(session.id, "agent:status", {
"session_id": session.id,
"status": "running",
})
return decision
async def run_browser_agent(
task: str,
browser_id: str,
@@ -188,6 +277,7 @@ async def run_browser_agent(
tab_id: str = "",
pre_selected: bool = False,
initial_url: str | None = None,
parent_session_id: str | None = None,
) -> dict:
"""Run a browser sub-agent loop for a single browser card.
@@ -196,6 +286,8 @@ async def run_browser_agent(
"""
from backend.apps.agents.agent_manager import agent_manager
_browser_perms = load_builtin_permissions()
session_id = uuid4().hex
session = AgentSession(
id=session_id,
@@ -206,6 +298,7 @@ async def run_browser_agent(
dashboard_id=dashboard_id,
browser_id=browser_id,
system_prompt=SYSTEM_PROMPT,
parent_session_id=parent_session_id,
)
agent_manager.sessions[session_id] = session
@@ -291,6 +384,48 @@ async def run_browser_agent(
tool_results = []
for tu in tool_uses:
policy = _browser_perms.get(tu.name, "always_allow")
if policy == "deny":
denied_text = f"Tool {tu.name} is denied by permission policy."
tool_results.append({
"type": "tool_result",
"tool_use_id": tu.id,
"content": [{"type": "text", "text": denied_text}],
})
result_msg = Message(
role="tool_result",
content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0},
)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
continue
if policy == "ask":
decision = await _request_browser_approval(
session, tu.name, tu.input,
)
if decision.get("behavior") == "deny":
denied_text = decision.get("message") or f"Tool {tu.name} denied by user."
tool_results.append({
"type": "tool_result",
"tool_use_id": tu.id,
"content": [{"type": "text", "text": denied_text}],
})
result_msg = Message(
role="tool_result",
content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0},
)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
continue
start = time.time()
result = await execute_browser_tool(
tu.name, tu.input, browser_id, tab_id,
@@ -407,6 +542,8 @@ async def _create_browser_card(dashboard_id: str, url: str) -> str:
activeTabId=tab_id,
x=40,
y=100,
width=1280,
height=800,
)
dashboard.layout.browser_cards[browser_id] = card
dashboard.updated_at = datetime.now()
@@ -425,6 +562,7 @@ async def run_browser_agents(
api_key: str,
dashboard_id: str | None = None,
pre_selected_browser_ids: list[str] | None = None,
parent_session_id: str | None = None,
) -> list[dict]:
"""Run multiple browser sub-agents in parallel.
@@ -451,6 +589,7 @@ async def run_browser_agents(
dashboard_id=dashboard_id,
pre_selected=is_pre_selected,
initial_url=url if url and browser_id not in pre_selected else None,
parent_session_id=parent_session_id,
)
results = await asyncio.gather(*[_run_one(t) for t in tasks], return_exceptions=True)
+61 -23
View File
@@ -25,26 +25,20 @@ BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser-agent/run"
MODEL = os.environ.get("OPENSWARM_AGENT_MODEL", "sonnet")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
PRE_SELECTED_BROWSER_IDS = os.environ.get("OPENSWARM_PRE_SELECTED_BROWSER_IDS", "")
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
TOOLS = [
{
"name": "BrowserAgent",
"name": "CreateBrowserAgent",
"description": (
"Delegate a browser task to a dedicated browser agent. The browser agent "
"Create a new browser card and run a task on it. A dedicated browser agent "
"will autonomously perform the task (navigating, clicking, typing, etc.) "
"and return a summary of actions taken plus a final screenshot. "
"Use this for any task that requires interacting with a web page."
"Use this when you need a fresh browser for a new task."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": (
"The ID of the browser card to use. If omitted, a new browser "
"card will be automatically created."
),
},
"task": {
"type": "string",
"description": (
@@ -55,20 +49,47 @@ TOOLS = [
"url": {
"type": "string",
"description": (
"Optional starting URL. If provided and no browser_id is given, "
"the new browser will navigate here first."
"Optional starting URL. The new browser will navigate here "
"before beginning the task."
),
},
},
"required": ["task"],
},
},
{
"name": "BrowserAgent",
"description": (
"Delegate a browser task to a dedicated browser agent on an existing "
"browser card. The browser agent will autonomously perform the task "
"(navigating, clicking, typing, etc.) and return a summary of actions "
"taken plus a final screenshot."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": (
"The task for the browser agent to perform. Be specific and "
"detailed about what you want accomplished."
),
},
},
"required": ["browser_id", "task"],
},
},
{
"name": "BrowserAgents",
"description": (
"Delegate multiple browser tasks to run in parallel, each on a different "
"browser. All tasks execute concurrently and results are returned together. "
"Use this when you need to perform tasks on multiple web pages simultaneously."
"Delegate multiple browser tasks to run in parallel, each on an existing "
"browser card. All tasks execute concurrently and results are returned "
"together. Use this when you need to perform tasks on multiple web pages "
"simultaneously."
),
"inputSchema": {
"type": "object",
@@ -81,18 +102,14 @@ TOOLS = [
"properties": {
"browser_id": {
"type": "string",
"description": "Optional browser card ID. If omitted, a new browser will be created.",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": "The task for this browser agent.",
},
"url": {
"type": "string",
"description": "Optional starting URL.",
},
},
"required": ["task"],
"required": ["browser_id", "task"],
},
},
},
@@ -119,6 +136,7 @@ def call_backend(tasks: list[dict]) -> dict:
"model": MODEL,
"dashboard_id": DASHBOARD_ID,
"pre_selected_browser_ids": pre_selected,
"parent_session_id": PARENT_SESSION_ID,
}).encode()
req = urllib.request.Request(
BACKEND_URL,
@@ -219,10 +237,10 @@ def format_batch_results(results: list[dict]) -> dict:
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name == "BrowserAgent":
if tool_name == "CreateBrowserAgent":
task_def = {
"task": arguments.get("task", ""),
"browser_id": arguments.get("browser_id", ""),
"browser_id": "",
"url": arguments.get("url", ""),
}
result = call_backend([task_def])
@@ -233,10 +251,30 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
return format_result(results[0])
return {"content": [{"type": "text", "text": "No result returned."}], "isError": True}
elif tool_name == "BrowserAgent":
browser_id = arguments.get("browser_id", "")
if not browser_id:
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
task_def = {
"task": arguments.get("task", ""),
"browser_id": browser_id,
"url": "",
}
result = call_backend([task_def])
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
results = result.get("results", [result])
if results:
return format_result(results[0])
return {"content": [{"type": "text", "text": "No result returned."}], "isError": True}
elif tool_name == "BrowserAgents":
tasks = arguments.get("tasks", [])
if not tasks:
return {"content": [{"type": "text", "text": "Error: tasks array is empty"}], "isError": True}
for t in tasks:
if not t.get("browser_id"):
return {"content": [{"type": "text", "text": "Error: browser_id is required for each task"}], "isError": True}
result = call_backend(tasks)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
+54
View File
@@ -181,6 +181,58 @@ TOOLS = [
"required": ["browser_id"],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": ["browser_id"],
},
},
]
@@ -260,6 +312,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"BrowserType": "type",
"BrowserEvaluate": "evaluate",
"BrowserGetElements": "get_elements",
"BrowserScroll": "scroll",
"BrowserWait": "wait",
}
action = action_map.get(tool_name)
if not action:
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Stdio MCP server that exposes the InvokeAgent tool.
Launched as a subprocess by the Claude Agent SDK. Proxies invocation
requests to the OpenSwarm backend via HTTP, which forks the target
agent session and runs it with the new message.
"""
import json
import sys
import os
import urllib.request
import urllib.error
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/invoke-agent/run"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
TOOLS = [
{
"name": "InvokeAgent",
"description": (
"Invoke a copy of an existing agent session with a new message. "
"The invoked agent will have full context of its prior conversation "
"and will process the new message independently. Use this when you "
"need to query another agent about its prior work or ask it to "
"perform a follow-up task."
),
"inputSchema": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": (
"The session ID of the agent to invoke. This is the ID "
"from a selected Agent Card in the context."
),
},
"message": {
"type": "string",
"description": (
"The message to send to the invoked agent. Be specific "
"about what you need from it."
),
},
},
"required": ["session_id", "message"],
},
},
]
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(session_id: str, message: str) -> dict:
payload = json.dumps({
"session_id": session_id,
"message": message,
"parent_session_id": PARENT_SESSION_ID,
"dashboard_id": DASHBOARD_ID,
}).encode()
req = urllib.request.Request(
BACKEND_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=600) 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 != "InvokeAgent":
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
session_id = arguments.get("session_id", "")
message = arguments.get("message", "")
if not session_id:
return {"content": [{"type": "text", "text": "Error: session_id is required"}], "isError": True}
if not message:
return {"content": [{"type": "text", "text": "Error: message is required"}], "isError": True}
result = call_backend(session_id, message)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
forked_id = result.get("forked_session_id", "")
response = result.get("response", "No response from invoked agent.")
cost = result.get("cost_usd", 0)
source_name = result.get("source_name", "")
lines = [f"**Invoked Agent Result** (forked session: {forked_id})"]
if source_name:
lines[0] = f"**Invoked Agent Result** — {source_name} (forked session: {forked_id})"
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-invoke-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
View File
@@ -37,6 +37,7 @@ class Message(BaseModel):
attached_skills: Optional[list[dict]] = None
forced_tools: Optional[list[str]] = None
images: Optional[list[dict]] = None
hidden: bool = False
class MessageBranch(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
@@ -72,3 +73,4 @@ class AgentSession(BaseModel):
tool_group_meta: dict[str, "ToolGroupMeta"] = Field(default_factory=dict)
dashboard_id: Optional[str] = None
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
+2 -2
View File
@@ -34,8 +34,8 @@ class BrowserCardPosition(BaseModel):
activeTabId: str = ""
x: float = 0
y: float = 0
width: float = 640
height: float = 480
width: float = 1280
height: float = 800
class DashboardLayout(BaseModel):
+21 -36
View File
@@ -76,43 +76,28 @@ BUILTIN_MODES: list[Mode] = [
),
Mode(
id="view-builder",
name="View Builder",
description="Create and iterate on reusable View artifacts.",
name="App Builder",
description="Create and iterate on reusable App artifacts.",
system_prompt=(
"You are helping the user build a reusable View — a self-contained "
"web app rendered in an iframe.\n\n"
"Your working directory is a dedicated workspace folder for this view. "
"You can create any file structure you need using the Write tool.\n\n"
"## Required files\n\n"
"1. **index.html** — The entry point. A complete HTML document. "
"React 18 is available via esm.sh CDN imports:\n"
' <script type="importmap">{"imports":{"react":"https://esm.sh/react@18",'
'"react-dom/client":"https://esm.sh/react-dom@18/client"}}</script>\n'
" The structured input data is available at `window.OUTPUT_INPUT` (object) "
"and any server-side result at `window.OUTPUT_BACKEND_RESULT`.\n\n"
"2. **schema.json** — A JSON Schema object defining the structured input "
"the view accepts. Example:\n"
' {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}\n\n'
"3. **meta.json** — Metadata for this view. Always write this file with "
"a short name and one-sentence description. Example:\n"
' {"name":"Sales Dashboard","description":"Interactive dashboard showing sales metrics"}\n\n'
"## Optional files\n\n"
"- **backend.py** — Python code that receives `input_data` as "
"a global dict and must assign its result to a global `result` dict.\n"
"- **Any additional files** — You can create subdirectories and split code "
"across multiple files. For example:\n"
" - `components/Chart.js` — Reusable components\n"
" - `utils/helpers.js` — Utility functions\n"
" - `styles/main.css` — Stylesheets\n\n"
"Files are served from the workspace, so relative imports work naturally:\n"
' `<script type="module" src="./components/Chart.js"></script>`\n'
' `<link rel="stylesheet" href="./styles/main.css">`\n'
" `import { helper } from './utils/helpers.js'` (in ES modules)\n\n"
"## Guidelines\n\n"
"Write files immediately when you have code ready. The user can see "
"a live preview that auto-refreshes from these files. Always write the "
"complete file content (do not use Edit for partial patches on first creation). "
"For complex views, split code into separate files to keep things organized."
"You are an App Builder — an AI assistant that creates self-contained "
"web apps rendered in an iframe preview.\n\n"
"Your working directory is a dedicated workspace folder pre-seeded with "
"template files. Read the existing files before making changes.\n\n"
"## Critical rules\n\n"
"- The entry point MUST be named `index.html`. Never rename it or create "
"a different HTML file as the main entry point.\n"
"- Write files immediately when you have code ready — the user sees a "
"live preview that auto-refreshes from these files.\n"
"- Always write the complete file content on first creation (do not use "
"Edit for partial patches on new files).\n"
"- For complex apps, split code into separate files (JS, CSS, etc.) "
"and reference them from index.html with relative paths.\n"
"- Always update meta.json with a short name and one-sentence description.\n"
"- Build beautiful, polished UIs with modern design — dark themes, smooth "
"transitions, proper spacing, and responsive layouts.\n\n"
"Read the SKILL.md reference in your workspace for the full technical "
"specification of the App platform (available globals, file conventions, "
"schema format, backend.py usage, and examples)."
),
tools=None,
default_next_mode=None,
+12 -1
View File
@@ -59,7 +59,8 @@ def load_mode(mode_id: str) -> Mode | None:
@modes.router.get("/list")
async def list_modes():
return {"modes": [m.model_dump() for m in _load_all()]}
builtin_defaults = {m.id: m.model_dump() for m in BUILTIN_MODES}
return {"modes": [m.model_dump() for m in _load_all()], "builtin_defaults": builtin_defaults}
@modes.router.get("/{mode_id}")
@@ -93,6 +94,16 @@ async def update_mode(mode_id: str, body: ModeUpdate):
return {"ok": True, "mode": mode.model_dump()}
@modes.router.post("/{mode_id}/reset")
async def reset_mode(mode_id: str):
"""Reset a built-in mode to its hardcoded defaults."""
builtin = next((m for m in BUILTIN_MODES if m.id == mode_id), None)
if not builtin:
raise HTTPException(status_code=400, detail="Only built-in modes can be reset")
_save(builtin)
return {"ok": True, "mode": builtin.model_dump()}
@modes.router.delete("/{mode_id}")
async def delete_mode(mode_id: str):
mode = _load(mode_id)
+9
View File
@@ -15,6 +15,7 @@ from backend.apps.outputs.models import (
WorkspaceSeedRequest,
)
from backend.apps.outputs.executor import execute_backend_code
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES
from backend.apps.settings.settings import load_settings
logger = logging.getLogger(__name__)
@@ -235,6 +236,14 @@ async def seed_workspace(body: WorkspaceSeedRequest):
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
else:
for rel_path, content in VIEW_TEMPLATE_FILES.items():
full_path = os.path.join(folder, rel_path)
with open(full_path, "w") as f:
f.write(content)
with open(os.path.join(folder, "SKILL.md"), "w") as f:
f.write(VIEW_BUILDER_SKILL)
if body.meta:
with open(os.path.join(folder, "meta.json"), "w") as f:
+223
View File
@@ -0,0 +1,223 @@
# App Builder — Platform Reference
You are building an **App**: a self-contained web app served in an iframe.
The workspace you're working in is the source of truth — every file you write
here is served directly to the live preview.
---
## File conventions
| File | Required | Purpose |
|------|----------|---------|
| `index.html` | **Yes** | Entry point. Must be a complete HTML document. This is the ONLY file the preview iframe loads — never rename it. |
| `meta.json` | **Yes** | `{"name":"…","description":"…"}` — displayed in the UI header. Always write this. |
| `schema.json` | Recommended | JSON Schema defining the input form (the "Test Input" tab). |
| `backend.py` | Optional | Server-side Python executed before rendering. |
| Everything else | Optional | JS, CSS, images, subdirectories — referenced from `index.html` via relative paths. |
### ⚠️ Do NOT
- Name the main HTML file anything other than `index.html` — the platform
will not find it and the preview will be blank.
- Use `document.write()` — it breaks the injected data globals.
- Assume any external server or API is available unless the user provides one.
---
## Injected globals
Before `index.html` loads, the platform injects two globals:
```javascript
window.OUTPUT_INPUT // Object — structured input from the schema form
window.OUTPUT_BACKEND_RESULT // Object | null — result from backend.py execution
```
These are available immediately in any `<script>` tag. You can also listen for
live updates when the user changes input:
```javascript
window.addEventListener('output-data-ready', () => {
const input = window.OUTPUT_INPUT;
const result = window.OUTPUT_BACKEND_RESULT;
// re-render with new data
});
```
---
## schema.json format
Standard JSON Schema. The platform renders a form from this automatically.
```json
{
"type": "object",
"properties": {
"title": { "type": "string", "default": "My Dashboard" },
"count": { "type": "number", "default": 10 },
"enabled": { "type": "boolean", "default": true },
"items": {
"type": "array",
"items": { "type": "string" },
"default": ["alpha", "beta"]
}
},
"required": ["title"]
}
```
Supported types: `string`, `number`, `integer`, `boolean`, `array`, `object`.
Use `"default"` values so the preview works without manual input.
---
## backend.py
Optional server-side Python that runs before the frontend renders.
It receives a global `input_data` dict (the schema form values) and must
assign its result to a global `result` dict.
```python
# input_data is pre-populated from the schema form
import json
result = {
"processed_items": [item.upper() for item in input_data.get("items", [])],
"timestamp": "2024-01-01T00:00:00Z",
}
```
The `result` dict becomes `window.OUTPUT_BACKEND_RESULT` in the frontend.
---
## Multi-file projects
Split code across files for organization. All files are served from the
workspace root, so relative imports work naturally:
```
workspace/
├── index.html
├── meta.json
├── schema.json
├── styles/
│ └── main.css
├── components/
│ └── Chart.js
└── utils/
└── helpers.js
```
Reference from `index.html`:
```html
<link rel="stylesheet" href="./styles/main.css">
<script type="module" src="./components/Chart.js"></script>
```
ES module imports between JS files:
```javascript
// components/Chart.js
import { formatNumber } from '../utils/helpers.js';
```
---
## Using React
React 18 is available via esm.sh CDN — no build step needed:
```html
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client"
}
}
</script>
<div id="root"></div>
<script type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
function App() {
const input = window.OUTPUT_INPUT || {};
return React.createElement('div', null,
React.createElement('h1', null, input.title || 'Hello')
);
}
createRoot(document.getElementById('root')).render(
React.createElement(App)
);
</script>
```
Other CDN libraries work too — use `https://esm.sh/` or `https://cdn.jsdelivr.net/npm/` for any npm package.
---
## Design guidelines
- **Dark theme by default** — use dark backgrounds (#0f1117, #1a1d27) with
light text (#e2e8f0) unless the user requests otherwise.
- **Modern aesthetics** — rounded corners (8-12px), subtle borders, box shadows,
smooth transitions (0.15-0.3s ease).
- **Responsive** — use flexbox/grid, test at different sizes.
- **Typography** — system font stack for UI, monospace for code/data.
- **Color accents** — use a single accent color with variations for hover/active states.
- **Spacing** — consistent padding (12-20px), adequate whitespace between sections.
- **Interactivity** — hover effects, focus states, loading indicators where appropriate.
---
## Complete minimal example
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1a1d27;
border: 1px solid #2e3248;
border-radius: 12px;
padding: 32px;
max-width: 480px;
width: 100%;
}
h1 { font-size: 1.5rem; margin-bottom: 8px; }
p { color: #8892a4; line-height: 1.6; }
</style>
</head>
<body>
<div class="card">
<h1 id="title">Loading…</h1>
<p id="desc"></p>
</div>
<script>
const input = window.OUTPUT_INPUT || {};
document.getElementById('title').textContent = input.title || 'Untitled';
document.getElementById('desc').textContent = input.description || 'No description provided.';
</script>
</body>
</html>
```
@@ -0,0 +1,74 @@
"""Default template files seeded into new App Builder workspaces."""
import os
_SKILL_PATH = os.path.join(os.path.dirname(__file__), "view_builder_skill.md")
with open(_SKILL_PATH) as _f:
VIEW_BUILDER_SKILL = _f.read()
VIEW_TEMPLATE_INDEX = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.container {
background: #1a1d27;
border: 1px solid #2e3248;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
text-align: center;
}
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
p { color: #8892a4; font-size: 0.95rem; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Ready</h1>
<p id="desc">Describe what you want to build and the agent will update this app.</p>
</div>
<script>
const input = window.OUTPUT_INPUT || {};
const result = window.OUTPUT_BACKEND_RESULT || null;
</script>
</body>
</html>
"""
VIEW_TEMPLATE_SCHEMA = """\
{
"type": "object",
"properties": {},
"required": []
}
"""
VIEW_TEMPLATE_META = """\
{
"name": "",
"description": ""
}
"""
VIEW_TEMPLATE_FILES = {
"index.html": VIEW_TEMPLATE_INDEX,
"schema.json": VIEW_TEMPLATE_SCHEMA,
"meta.json": VIEW_TEMPLATE_META,
}
+5
View File
@@ -29,3 +29,8 @@ class AppSettings(BaseModel):
deepgram_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
webhook_base_url: Optional[str] = None
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
+18
View File
@@ -5,6 +5,7 @@ from uuid import uuid4
class BuiltinTool(BaseModel):
name: str
display_name: Optional[str] = None
description: str
category: str = "filesystem"
deferred: bool = False
@@ -33,6 +34,23 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True),
BuiltinTool(name="RenderOutput", description="Render a reusable View artifact with structured input data", category="views", deferred=True),
# Agent tools
BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
# Browser delegation tools (Layer 1 — what the main agent calls)
BuiltinTool(name="CreateBrowserAgent", description="Create a new browser and run a task on it", category="browser_delegation"),
BuiltinTool(name="BrowserAgent", description="Delegate a browser task to an existing browser agent", category="browser_delegation"),
BuiltinTool(name="BrowserAgents", description="Run multiple browser tasks in parallel on existing browsers", category="browser_delegation"),
# Browser action tools (Layer 2 — what the sub-agent executes)
BuiltinTool(name="BrowserScreenshot", description="Capture a screenshot of the browser page", category="browser_action"),
BuiltinTool(name="BrowserNavigate", description="Navigate the browser to a URL", category="browser_action"),
BuiltinTool(name="BrowserClick", description="Click an element by CSS selector", category="browser_action"),
BuiltinTool(name="BrowserType", description="Type text into an input element", category="browser_action"),
BuiltinTool(name="BrowserEvaluate", description="Execute JavaScript in the browser", category="browser_action"),
BuiltinTool(name="BrowserGetText", description="Get visible text content of the page", category="browser_action"),
BuiltinTool(name="BrowserGetElements", description="List interactive elements with CSS selectors", category="browser_action"),
BuiltinTool(name="BrowserScroll", description="Scroll the page up or down", category="browser_action"),
BuiltinTool(name="BrowserWait", description="Wait for page loads or animations", category="browser_action"),
]
+37 -7
View File
@@ -18,9 +18,11 @@ from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate
logger = logging.getLogger(__name__)
from backend.config.paths import BACKEND_DIR, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
if os.environ.get("OPENSWARM_PACKAGED") == "1":
load_dotenv(os.path.join(os.path.dirname(DATA_ROOT), ".env"), override=True)
@asynccontextmanager
@@ -380,6 +382,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
config["command"] = resolved
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
env.setdefault("PYTHONPATH", "")
return config
@@ -510,7 +513,7 @@ async def _discover_mcp_tools_http(url: str, headers: dict | None = None) -> lis
raise HTTPException(status_code=502, detail="Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
return [{"name": t.get("name", ""), "description": t.get("description", "")} for t in tools_list]
return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list[dict]:
@@ -533,7 +536,7 @@ async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list
) as session:
await session.initialize()
result = await session.list_tools()
return [{"name": t.name, "description": t.description or ""} for t in result.tools]
return [{"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema if t.inputSchema else None} for t in result.tools]
except BaseExceptionGroup as eg:
first = eg.exceptions[0] if eg.exceptions else eg
raise HTTPException(status_code=502, detail=f"SSE discovery failed: {first}") from first
@@ -546,6 +549,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations")
proc_env = {**os.environ, **(env or {}), "PATH": _augmented_path()}
proc_env.pop("PYTHONPATH", None)
proc = await asyncio.create_subprocess_exec(
cmd_path, *(args or []),
@@ -601,7 +605,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
data = await _recv()
tools_list = data.get("result", {}).get("tools", [])
return [{"name": t.get("name", ""), "description": t.get("description", "")} for t in tools_list]
return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
except HTTPException:
raise
@@ -616,12 +620,34 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=5.0)
except Exception:
proc.kill()
try:
proc.kill()
except Exception:
pass
@tools_lib.router.post("/{tool_id}/discover")
async def discover_tools(tool_id: str):
tool = _load(tool_id)
if tool.auth_type == "oauth2" and tool.auth_status == "connected":
refreshed = await refresh_google_token(tool)
if not refreshed and tool.oauth_tokens.get("access_token"):
expiry = tool.oauth_tokens.get("token_expiry", 0)
if time.time() >= expiry - 60:
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
if not client_id:
raise HTTPException(
status_code=400,
detail="OAuth token expired and GOOGLE_OAUTH_CLIENT_ID is not set. "
"In the packaged app, create ~/.openswarm.env or "
"~/Library/Application Support/OpenSwarm/.env with your Google OAuth credentials.",
)
raise HTTPException(
status_code=502,
detail="OAuth token expired and refresh failed. Try reconnecting Google.",
)
config = derive_mcp_config(tool)
if not config:
raise HTTPException(status_code=400, detail="Cannot derive MCP config for tool")
@@ -655,8 +681,11 @@ async def discover_tools(tool_id: str):
except HTTPException:
raise
except Exception as e:
logger.warning(f"MCP tool discovery failed for {tool.name}: {e}")
raise HTTPException(status_code=502, detail=f"Discovery failed: {e}")
msg = str(e).strip()
if not msg:
msg = type(e).__name__
logger.warning(f"MCP tool discovery failed for {tool.name}: {msg}", exc_info=True)
raise HTTPException(status_code=502, detail=f"Discovery failed: {msg}")
services: dict[str, dict[str, list[str]]] = {}
service_groups: dict[str, list[str]] = {}
@@ -681,6 +710,7 @@ async def discover_tools(tool_id: str):
permissions["_services"] = services
permissions["_service_groups"] = service_groups
permissions["_tool_descriptions"] = {t["name"]: t["description"] for t in raw_tools}
permissions["_tool_schemas"] = {t["name"]: t.get("inputSchema") for t in raw_tools if t.get("inputSchema")}
tool.tool_permissions = permissions
_save(tool)
+36
View File
@@ -1,6 +1,9 @@
import logging
import os
from uuid import uuid4
logger = logging.getLogger(__name__)
from fastapi.responses import JSONResponse
from fastapi import Request
from backend.config.Apps import MainApp
@@ -133,6 +136,7 @@ async def browser_agent_run(request: Request):
model = body.get("model", "sonnet")
dashboard_id = body.get("dashboard_id", "")
pre_selected_browser_ids = body.get("pre_selected_browser_ids", [])
parent_session_id = body.get("parent_session_id", "")
if not tasks:
return JSONResponse({"error": "tasks array is required"}, status_code=400)
@@ -147,10 +151,42 @@ async def browser_agent_run(request: Request):
api_key=settings.anthropic_api_key,
dashboard_id=dashboard_id or None,
pre_selected_browser_ids=pre_selected_browser_ids,
parent_session_id=parent_session_id or None,
)
return JSONResponse({"results": results})
@app.post("/api/invoke-agent/run")
async def invoke_agent_run(request: Request):
"""Fork an existing agent session and send it a new message.
Called by the invoke_agent_mcp_server stdio subprocess."""
body = await request.json()
session_id = body.get("session_id", "")
message = body.get("message", "")
parent_session_id = body.get("parent_session_id", "")
dashboard_id = body.get("dashboard_id", "")
if not session_id:
return JSONResponse({"error": "session_id is required"}, status_code=400)
if not message:
return JSONResponse({"error": "message is required"}, status_code=400)
try:
from backend.apps.agents.agent_manager import agent_manager
result = await agent_manager.invoke_agent(
source_session_id=session_id,
message=message,
parent_session_id=parent_session_id or None,
dashboard_id=dashboard_id or None,
)
return JSONResponse(result)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=404)
except Exception as e:
logger.exception("invoke_agent_run failed")
return JSONResponse({"error": str(e)}, status_code=500)
if __name__ == "__main__":
import argparse
import uvicorn
+213 -12
View File
@@ -1,5 +1,6 @@
const { app, BrowserWindow, ipcMain, shell } = require('electron');
const { autoUpdater } = require('electron-updater');
const { app, components, BrowserWindow, ipcMain, shell, session } = require('electron');
let autoUpdater;
try { autoUpdater = require('electron-updater').autoUpdater; } catch (_) {}
const path = require('path');
const { spawn, execFileSync } = require('child_process');
const os = require('os');
@@ -7,9 +8,16 @@ const fs = require('fs');
const getPort = require('get-port');
const http = require('http');
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
let mainWindow = null;
let backendProcess = null;
let backendPort = null;
let cachedUpdateStatus = { status: 'idle', info: null, error: null };
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
@@ -24,9 +32,10 @@ const iconPath = path.join(__dirname, 'build', 'icon.png');
function getShellPath() {
if (process.platform !== 'darwin' || isDev) return process.env.PATH || '';
// Strategy 1: ask the user's login shell for its PATH
try {
const shell = process.env.SHELL || '/bin/zsh';
const result = execFileSync(shell, ['-ilc', 'echo $PATH'], {
const userShell = process.env.SHELL || '/bin/zsh';
const result = execFileSync(userShell, ['-ilc', 'echo $PATH'], {
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, HOME: os.homedir() },
@@ -35,19 +44,40 @@ function getShellPath() {
if (resolved) return resolved;
} catch (_) { /* fall through */ }
// Strategy 2: read macOS system PATH config (/etc/paths + /etc/paths.d/*)
const systemPaths = [];
try {
const base = fs.readFileSync('/etc/paths', 'utf8');
for (const line of base.split('\n')) {
const p = line.trim();
if (p) systemPaths.push(p);
}
} catch (_) { /* ignore */ }
try {
const pathsD = '/etc/paths.d';
if (fs.existsSync(pathsD)) {
for (const file of fs.readdirSync(pathsD).sort()) {
const content = fs.readFileSync(path.join(pathsD, file), 'utf8');
for (const line of content.split('\n')) {
const p = line.trim();
if (p) systemPaths.push(p);
}
}
}
} catch (_) { /* ignore */ }
// Strategy 3: well-known user-local bin directories
const home = os.homedir();
const fallbackDirs = [
path.join(home, '.nvm/versions/node'),
path.join(home, '.local/bin'),
path.join(home, '.volta/bin'),
path.join(home, '.fnm/aliases/default/bin'),
path.join(home, '.bun/bin'),
path.join(home, '.cargo/bin'),
path.join(home, '.local/bin'),
'/opt/homebrew/bin',
'/usr/local/bin',
];
// For nvm, resolve the current default version dynamically
const nvmDir = path.join(home, '.nvm/versions/node');
try {
if (fs.existsSync(nvmDir)) {
@@ -58,10 +88,14 @@ function getShellPath() {
}
} catch (_) { /* ignore */ }
const existing = fallbackDirs.filter((d) => {
try { return fs.statSync(d).isDirectory(); } catch { return false; }
});
return [...existing, process.env.PATH || ''].join(':');
const seen = new Set();
const dirs = [];
for (const d of [...fallbackDirs, ...systemPaths, ...(process.env.PATH || '').split(':')]) {
if (!d || seen.has(d)) continue;
seen.add(d);
try { if (fs.statSync(d).isDirectory()) dirs.push(d); } catch { /* skip */ }
}
return dirs.join(':');
}
function getResourcePath(...segments) {
@@ -190,6 +224,18 @@ function createWindow() {
mainWindow.loadFile(frontendPath);
}
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, _params) => {
webPreferences.plugins = true;
webPreferences.enableBlinkFeatures = 'EncryptedMedia';
});
mainWindow.webContents.on('will-navigate', (event, url) => {
if (isDev && url.startsWith('http://localhost:3000')) return;
if (url.startsWith('file://')) return;
event.preventDefault();
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
});
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -202,30 +248,36 @@ function sendToRenderer(channel, ...args) {
}
function setupAutoUpdater() {
if (!autoUpdater) return;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
cachedUpdateStatus = { status: 'available', info, error: null };
sendToRenderer('update-available', info);
});
autoUpdater.on('update-not-available', (info) => {
console.log('App is up to date');
cachedUpdateStatus = { status: 'not-available', info, error: null };
sendToRenderer('update-not-available', info);
});
autoUpdater.on('download-progress', (progress) => {
cachedUpdateStatus = { status: 'downloading', info: progress, error: null };
sendToRenderer('download-progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`Update downloaded: ${info.version}`);
cachedUpdateStatus = { status: 'downloaded', info, error: null };
sendToRenderer('update-downloaded', info);
});
autoUpdater.on('error', (err) => {
console.error('Auto-update error:', err);
cachedUpdateStatus = { status: 'error', info: null, error: err?.message || String(err) };
sendToRenderer('update-error', err?.message || String(err));
});
@@ -252,6 +304,68 @@ app.whenReady().then(async () => {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
const allowed = [
'media', 'mediaKeySystem', 'protected-media-identifier',
'geolocation', 'notifications', 'midi', 'midiSysex',
'clipboard-read', 'clipboard-sanitized-write',
'pointerLock', 'fullscreen', 'idle-detection',
];
console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied');
callback(allowed.includes(permission));
});
session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
const allowed = [
'media', 'mediaKeySystem', 'protected-media-identifier',
'clipboard-read', 'clipboard-sanitized-write',
'pointerLock', 'fullscreen', 'idle-detection',
];
return allowed.includes(permission);
});
// Read-only logging for DRM license requests — no modifying interceptors
// so the network stack can set Content-Type and other headers normally.
session.defaultSession.webRequest.onSendHeaders(
{ urls: ['*://*/*widevine*license*'] },
(details) => {
console.log(`[drm-req] ${details.method} ${details.url}`);
for (const [k, v] of Object.entries(details.requestHeaders || {})) {
if (/content-type|origin|referer|auth|accept/i.test(k)) {
console.log(`[drm-req] ${k}: ${v}`);
}
}
},
);
session.defaultSession.webRequest.onCompleted(
{ urls: ['*://*/*widevine*', '*://*/*license*'] },
(details) => {
console.log(`[drm-net] ${details.method} ${details.url}${details.statusCode}`);
},
);
session.defaultSession.webRequest.onErrorOccurred(
{ urls: ['*://*/*widevine*', '*://*/*license*'] },
(details) => {
console.log(`[drm-net] FAILED ${details.method} ${details.url}${details.error}`);
},
);
// Wait for the Widevine CDM to be downloaded/ready (CastLabs Component
// Updater Service). On first launch this downloads the CDM; subsequent
// launches use the cached version.
if (components && typeof components.whenReady === 'function') {
try {
await components.whenReady();
console.log('Widevine CDM ready');
if (typeof components.status === 'function') {
console.log('CDM component status:', JSON.stringify(components.status()));
}
} catch (err) {
console.warn('Widevine CDM not available:', err.message);
}
} else {
console.log('CastLabs components API not available — using standard Electron (no DRM)');
}
try {
if (isDev) {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
@@ -262,6 +376,13 @@ app.whenReady().then(async () => {
createWindow();
if (!isDev) {
setupAutoUpdater();
mainWindow.webContents.on('did-finish-load', () => {
if (cachedUpdateStatus.status === 'available') {
sendToRenderer('update-available', cachedUpdateStatus.info);
} else if (cachedUpdateStatus.status === 'downloaded') {
sendToRenderer('update-downloaded', cachedUpdateStatus.info);
}
});
}
} catch (err) {
console.error('Failed to start:', err);
@@ -269,6 +390,79 @@ app.whenReady().then(async () => {
}
});
app.on('web-contents-created', (_event, contents) => {
contents.setWindowOpenHandler(({ url, disposition }) => {
if (disposition === 'foreground-tab' || disposition === 'background-tab') {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('webview-new-window', url, contents.id);
}
return { action: 'deny' };
}
return {
action: 'allow',
overrideBrowserWindowOptions: {
parent: mainWindow || undefined,
},
};
});
contents.on('did-create-window', (childWindow) => {
if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) {
childWindow.setParentWindow(mainWindow);
}
});
if (contents.getType() === 'webview') {
contents.on('console-message', (_e, level, message, line, sourceId) => {
if (message.includes('widevine') || message.includes('drm') ||
message.includes('license') || message.includes('MediaKeySession') ||
message.includes('EME') || message.includes('[drm-diag]') || level >= 2) {
const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG';
const src = sourceId ? sourceId.split('/').pop() : '';
console.log(`[webview:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`);
}
});
contents.on('dom-ready', () => {
const url = contents.getURL();
if (url.includes('spotify')) {
contents.executeJavaScript(`
(function() {
const origFetch = window.fetch;
window.fetch = async function(...args) {
const resp = await origFetch.apply(this, args);
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (url.includes('widevine-license') && !resp.ok) {
const clone = resp.clone();
try {
const text = await clone.text();
console.log('[drm-diag] License response ' + resp.status + ': ' + text.substring(0, 500));
} catch(e) {}
}
return resp;
};
// Check EME availability
if (navigator.requestMediaKeySystemAccess) {
navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
initDataTypes: ['cenc'],
audioCapabilities: [{contentType: 'audio/mp4; codecs="mp4a.40.2"'}],
}]).then(function(access) {
console.log('[drm-diag] Widevine EME access: ' + access.keySystem);
}).catch(function(err) {
console.log('[drm-diag] Widevine EME FAILED: ' + err.message);
});
} else {
console.log('[drm-diag] EME API not available');
}
})();
`).catch(() => {});
}
});
}
});
app.on('window-all-closed', () => {
if (!isDev) killBackend();
app.quit();
@@ -286,9 +480,14 @@ app.on('activate', () => {
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('get-webview-preload-path', () => {
return `file://${path.join(__dirname, 'webview-preload.js')}`;
});
ipcMain.handle('get-update-status', () => cachedUpdateStatus);
ipcMain.handle('check-for-updates', async () => {
if (!isPackaged) {
if (!autoUpdater || !isPackaged) {
sendToRenderer('update-error', 'Update check is only available in the packaged app.');
return { success: false, error: 'Not packaged' };
}
@@ -305,6 +504,7 @@ ipcMain.handle('check-for-updates', async () => {
});
ipcMain.handle('download-update', async () => {
if (!autoUpdater) return { success: false, error: 'Updater not available' };
try {
await autoUpdater.downloadUpdate();
return { success: true };
@@ -314,6 +514,7 @@ ipcMain.handle('download-update', async () => {
});
ipcMain.handle('install-update', () => {
if (!autoUpdater) return;
autoUpdater.quitAndInstall(false, true);
});
+12 -12
View File
@@ -1,19 +1,20 @@
{
"name": "openswarm",
"version": "1.0.3",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.3",
"version": "1.0.9",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
"get-port": "^5.1.1"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"electron": "^33.0.0",
"electron": "castlabs/electron-releases#v33.4.11+wvcus",
"electron-builder": "^25.1.0"
}
},
@@ -2206,9 +2207,8 @@
}
},
"node_modules/electron": {
"version": "33.4.11",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
"integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
"version": "33.4.11+wvcus",
"resolved": "git+ssh://git@github.com/castlabs/electron-releases.git#d1cf58c11ec0a8a04f307ed362d7efde2816778d",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3953,9 +3953,9 @@
}
},
"node_modules/node-abi": {
"version": "3.88.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
"integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4605,9 +4605,9 @@
}
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
+13 -17
View File
@@ -1,11 +1,13 @@
{
"name": "openswarm",
"version": "1.0.3",
"version": "1.0.10",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "ELECTRON_DEV=1 electron .",
"postinstall": "bash scripts/sign-vmp.sh",
"sign-vmp": "bash scripts/sign-vmp.sh",
"dist": "electron-builder --mac --publish never",
"dist:publish": "electron-builder --mac --publish always",
"dist:all": "electron-builder --mac --win --linux"
@@ -16,12 +18,15 @@
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"electron": "^33.0.0",
"electron": "castlabs/electron-releases#v33.4.11+wvcus",
"electron-builder": "^25.1.0"
},
"build": {
"appId": "com.clusterlabs.openswarm",
"productName": "OpenSwarm",
"electronDownload": {
"mirror": "https://github.com/castlabs/electron-releases/releases/download/v"
},
"directories": {
"output": "dist"
},
@@ -38,6 +43,7 @@
"entitlementsInherit": "build/entitlements.mac.plist"
},
"dmg": {
"artifactName": "OpenSwarm-${arch}.${ext}",
"title": "OpenSwarm",
"contents": [
{
@@ -54,34 +60,24 @@
},
"extraResources": [
{
"from": "../frontend/dist",
"from": "build-staging/frontend",
"to": "frontend",
"filter": [
"**/*"
]
},
{
"from": "../backend",
"from": "build-staging/backend",
"to": "backend",
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!.venv/**",
"!*.pyc"
"**/*"
]
},
{
"from": "../debugger",
"from": "build-staging/debugger",
"to": "debugger",
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!*.pyc",
"!.venv/**",
"!**/.venv/**",
"!**/node_modules/**"
"**/*"
]
},
{
+9
View File
@@ -2,15 +2,18 @@ const { contextBridge, ipcRenderer } = require('electron');
(async () => {
const port = await ipcRenderer.invoke('get-backend-port');
const webviewPreloadPath = await ipcRenderer.invoke('get-webview-preload-path');
contextBridge.exposeInMainWorld('__OPENSWARM_PORT__', port);
contextBridge.exposeInMainWorld('openswarm', {
getBackendPort: () => port,
getWebviewPreloadPath: () => webviewPreloadPath,
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
@@ -40,5 +43,11 @@ const { contextBridge, ipcRenderer } = require('electron');
ipcRenderer.on('update-error', listener);
return () => ipcRenderer.removeListener('update-error', listener);
},
onWebviewNewWindow: (cb) => {
const listener = (_event, url, webContentsId) => cb(url, webContentsId);
ipcRenderer.on('webview-new-window', listener);
return () => ipcRenderer.removeListener('webview-new-window', listener);
},
});
})();
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# Signs the CastLabs Electron binary with a production VMP certificate via EVS,
# then repairs macOS framework symlinks that npm/signing may strip.
#
# First-time setup (one-time):
# pip3 install --user castlabs-evs
# python3 -m castlabs_evs.account signup
#
# After signup, this script runs automatically.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ELECTRON_DIR="$SCRIPT_DIR/../node_modules/electron/dist"
FW_BASE="$ELECTRON_DIR/Electron.app/Contents/Frameworks"
fix_framework_symlinks() {
[ -d "$FW_BASE" ] || return 0
for fw in "$FW_BASE"/*.framework; do
[ -d "$fw/Versions/A" ] || continue
local name
name=$(basename "$fw" .framework)
cd "$fw"
(cd Versions && ln -sf A Current 2>/dev/null)
ln -sf "Versions/Current/$name" "$name" 2>/dev/null
[ -d "Versions/A/Resources" ] && ln -sf Versions/Current/Resources Resources 2>/dev/null
[ -d "Versions/A/Libraries" ] && ln -sf Versions/Current/Libraries Libraries 2>/dev/null
[ -d "Versions/A/Helpers" ] && ln -sf Versions/Current/Helpers Helpers 2>/dev/null
done
}
if [ ! -d "$ELECTRON_DIR" ]; then
echo "[vmp] Electron dist not found at $ELECTRON_DIR — skipping VMP signing"
fix_framework_symlinks
exit 0
fi
# Always fix symlinks first (npm git installs strip them)
fix_framework_symlinks
if ! python3 -c "import castlabs_evs" 2>/dev/null; then
echo "[vmp] castlabs-evs not installed. Install with: pip3 install --user castlabs-evs"
echo "[vmp] Skipping VMP signing — DRM playback will be limited"
exit 0
fi
VERIFY_OUTPUT=$(python3 -m castlabs_evs.vmp verify-pkg "$ELECTRON_DIR" 2>&1)
if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPUT" | grep -q "development only"; then
echo "[vmp] Electron already has a valid production VMP signature"
exit 0
fi
echo "[vmp] Signing Electron with production VMP certificate..."
if python3 -m castlabs_evs.vmp sign-pkg "$ELECTRON_DIR" 2>&1; then
echo "[vmp] VMP signing successful — full DRM playback enabled"
# Re-fix symlinks in case signing modified the bundle
fix_framework_symlinks
else
echo "[vmp] VMP signing failed — you may need to run: python3 -m castlabs_evs.account signup"
echo "[vmp] DRM playback will be limited to previews until signed"
fi
exit 0
+87
View File
@@ -0,0 +1,87 @@
/**
* Webview preload script — patches browser fingerprinting so sites like
* Spotify/Netflix don't detect an Electron shell and disable features.
* Loaded via the webview's `preload` attribute before any page script runs.
*/
'use strict';
// Hide webdriver flag
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
configurable: true,
});
// Spoof navigator.plugins (Chrome has a few built-in ones)
const fakePlugins = {
0: { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
1: { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
2: { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
length: 3,
item: (i) => fakePlugins[i] || null,
namedItem: (name) => {
for (let i = 0; i < fakePlugins.length; i++) {
if (fakePlugins[i].name === name) return fakePlugins[i];
}
return null;
},
refresh: () => {},
[Symbol.iterator]: function* () {
for (let i = 0; i < this.length; i++) yield this[i];
},
};
try {
Object.defineProperty(navigator, 'plugins', {
get: () => fakePlugins,
configurable: true,
});
} catch (_) {}
// Ensure window.chrome exists (sites test for it)
if (!window.chrome) {
window.chrome = {};
}
if (!window.chrome.runtime) {
window.chrome.runtime = {
connect: () => {},
sendMessage: () => {},
onMessage: { addListener: () => {}, removeListener: () => {} },
};
}
// Ensure navigator.languages has sensible values
try {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
configurable: true,
});
} catch (_) {}
// Patch permissions.query to report 'granted' for common permissions
const originalQuery = navigator.permissions?.query?.bind(navigator.permissions);
if (originalQuery) {
navigator.permissions.query = (params) => {
if (params.name === 'notifications') {
return Promise.resolve({ state: 'granted', onchange: null });
}
return originalQuery(params).catch(() =>
Promise.resolve({ state: 'prompt', onchange: null })
);
};
}
// Prevent iframe detection heuristics
try {
Object.defineProperty(document, 'hidden', {
get: () => false,
configurable: true,
});
Object.defineProperty(document, 'visibilityState', {
get: () => 'visible',
configurable: true,
});
} catch (_) {}
// Fix console.debug detection (some sites use it as a breakpoint detector)
const noop = () => {};
if (!window.console.debug) window.console.debug = noop;
+22 -1
View File
@@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { store } from '../shared/state/store';
import { useAppDispatch } from '@/shared/hooks';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import {
setAppVersion,
@@ -156,9 +156,15 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
const theme = useAppSelector((s) => s.settings.data.theme);
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
}, [loaded, theme, setThemeMode]);
return <>{children}</>;
};
@@ -171,6 +177,21 @@ const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) =
api.getAppVersion().then((v: string) => dispatch(setAppVersion(v)));
api.getUpdateStatus?.().then((cached) => {
if (!cached) return;
if (cached.status === 'available' && cached.info?.version) {
dispatch(setUpdateAvailable(cached.info.version));
} else if (cached.status === 'not-available') {
dispatch(setUpdateNotAvailable());
} else if (cached.status === 'downloading' && cached.info?.percent != null) {
dispatch(setDownloading(cached.info.percent));
} else if (cached.status === 'downloaded') {
dispatch(setUpdateDownloaded());
} else if (cached.status === 'error' && cached.error) {
dispatch(setUpdateError(cached.error));
}
});
const cleanups = [
api.onUpdateAvailable?.((info: OpenSwarmUpdateInfo) => dispatch(setUpdateAvailable(info.version))),
api.onUpdateNotAvailable?.(() => dispatch(setUpdateNotAvailable())),
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef, useCallback, RefObject } from 'react';
import React, { createContext, useContext, useState, useRef, useCallback, useMemo, RefObject } from 'react';
export interface SelectedElement {
id: string;
@@ -20,11 +20,17 @@ interface ElementSelectionContextValue {
setSelectMode: (active: boolean) => void;
excludeSelectId: string | null;
setExcludeSelectId: (id: string | null) => void;
activeOwnerId: string | null;
setActiveOwnerId: (id: string | null) => void;
selectedElements: SelectedElement[];
addSelectedElement: (el: SelectedElement) => void;
updateSelectedElement: (id: string, patch: Partial<SelectedElement>) => void;
removeSelectedElement: (id: string) => void;
clearSelectedElements: () => void;
elementsByOwner: Record<string, SelectedElement[]>;
addElementForOwner: (ownerId: string, el: SelectedElement) => void;
removeOwnerElement: (ownerId: string, elementId: string) => void;
clearOwnerElements: (ownerId: string) => void;
iframeRef: RefObject<HTMLIFrameElement | null>;
}
@@ -37,9 +43,18 @@ export function useElementSelection() {
export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectMode, setSelectMode] = useState(false);
const [excludeSelectId, setExcludeSelectId] = useState<string | null>(null);
const [selectedElements, setSelectedElements] = useState<SelectedElement[]>([]);
const [activeOwnerId, setActiveOwnerId] = useState<string | null>(null);
const [elementsByOwner, setElementsByOwner] = useState<Record<string, SelectedElement[]>>({});
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const activeOwnerIdRef = useRef(activeOwnerId);
activeOwnerIdRef.current = activeOwnerId;
const selectedElements = useMemo(
() => (activeOwnerId ? elementsByOwner[activeOwnerId] ?? [] : []),
[activeOwnerId, elementsByOwner],
);
const toggleSelectMode = useCallback(() => {
setSelectMode((prev) => {
if (prev) setExcludeSelectId(null);
@@ -48,22 +63,65 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
}, []);
const addSelectedElement = useCallback((el: SelectedElement) => {
setSelectedElements((prev) => {
if (prev.some((e) => e.id === el.id)) return prev;
return [...prev, el];
const ownerId = activeOwnerIdRef.current;
if (!ownerId) return;
setElementsByOwner((prev) => {
const existing = prev[ownerId] ?? [];
if (existing.some((e) => e.id === el.id)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
}, []);
const updateSelectedElement = useCallback((id: string, patch: Partial<SelectedElement>) => {
setSelectedElements((prev) => prev.map((e) => e.id === id ? { ...e, ...patch } : e));
const ownerId = activeOwnerIdRef.current;
if (!ownerId) return;
setElementsByOwner((prev) => {
const existing = prev[ownerId];
if (!existing) return prev;
return { ...prev, [ownerId]: existing.map((e) => (e.id === id ? { ...e, ...patch } : e)) };
});
}, []);
const removeSelectedElement = useCallback((id: string) => {
setSelectedElements((prev) => prev.filter((e) => e.id !== id));
const ownerId = activeOwnerIdRef.current;
if (!ownerId) return;
setElementsByOwner((prev) => {
const existing = prev[ownerId];
if (!existing) return prev;
return { ...prev, [ownerId]: existing.filter((e) => e.id !== id) };
});
}, []);
const clearSelectedElements = useCallback(() => {
setSelectedElements([]);
const ownerId = activeOwnerIdRef.current;
if (!ownerId) return;
setElementsByOwner((prev) => {
if (!prev[ownerId]?.length) return prev;
return { ...prev, [ownerId]: [] };
});
}, []);
const addElementForOwner = useCallback((ownerId: string, el: SelectedElement) => {
setElementsByOwner((prev) => {
const existing = prev[ownerId] ?? [];
if (existing.some((e) => e.semanticData?.selectId === el.semanticData?.selectId)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
}, []);
const removeOwnerElement = useCallback((ownerId: string, elementId: string) => {
setElementsByOwner((prev) => {
const existing = prev[ownerId];
if (!existing) return prev;
return { ...prev, [ownerId]: existing.filter((e) => e.id !== elementId) };
});
}, []);
const clearOwnerElements = useCallback((ownerId: string) => {
setElementsByOwner((prev) => {
if (!prev[ownerId]?.length) return prev;
return { ...prev, [ownerId]: [] };
});
}, []);
return (
@@ -74,11 +132,17 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
setSelectMode,
excludeSelectId,
setExcludeSelectId,
activeOwnerId,
setActiveOwnerId,
selectedElements,
addSelectedElement,
updateSelectedElement,
removeSelectedElement,
clearSelectedElements,
elementsByOwner,
addElementForOwner,
removeOwnerElement,
clearOwnerElements,
iframeRef,
}}
>
@@ -3,11 +3,23 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
import CloseIcon from '@mui/icons-material/Close';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { handleApproval, ApprovalRequest } from '@/shared/state/agentsSlice';
import {
handleApproval,
stopAgent,
dismissAgentNotification,
ApprovalRequest,
AgentSession,
HistorySession,
} from '@/shared/state/agentsSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import ApprovalBar, { BatchApprovalBar } from '@/app/pages/AgentChat/ApprovalBar';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -17,16 +29,133 @@ interface SessionApprovalGroup {
approvals: ApprovalRequest[];
}
type TrackedAgent = {
id: string;
name: string;
status: AgentSession['status'] | string;
dashboardId?: string;
};
const STATUS_CONFIG: Record<string, { color: string; label: string; tokenKey?: string }> = {
running: { color: '', label: 'Running', tokenKey: 'success' },
waiting_approval: { color: '', label: 'Waiting', tokenKey: 'warning' },
completed: { color: '', label: 'Done', tokenKey: 'success' },
error: { color: '', label: 'Error', tokenKey: 'error' },
stopped: { color: '', label: 'Stopped', tokenKey: 'info' },
};
const StatusDot: React.FC<{ status: string; c: ReturnType<typeof useClaudeTokens> }> = ({ status, c }) => {
const cfg = STATUS_CONFIG[status];
const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
const isActive = status === 'running';
return (
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: color,
flexShrink: 0,
...(isActive && {
animation: 'agentPulse 1.8s ease-in-out infinite',
'@keyframes agentPulse': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.5, transform: 'scale(1.3)' },
},
}),
}}
/>
);
};
const AgentStatusRow: React.FC<{
agent: TrackedAgent;
c: ReturnType<typeof useClaudeTokens>;
onStop: (id: string) => void;
onDismiss: (id: string) => void;
onNavigate: (dashboardId: string, agentId: string) => void;
}> = ({ agent, c, onStop, onDismiss, onNavigate }) => {
const isActive = agent.status === 'running' || agent.status === 'waiting_approval';
const cfg = STATUS_CONFIG[agent.status] ?? { label: agent.status };
return (
<Box
onClick={() => agent.dashboardId && onNavigate(agent.dashboardId, agent.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 0.75,
cursor: agent.dashboardId ? 'pointer' : 'default',
'&:hover': { bgcolor: `${c.text.ghost}10` },
transition: 'background-color 0.15s',
minHeight: 36,
}}
>
<StatusDot status={agent.status} c={c} />
<Typography
sx={{
fontSize: '0.8rem',
fontWeight: 500,
color: c.text.primary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{agent.name}
</Typography>
<Typography
sx={{
fontSize: '0.65rem',
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.03em',
flexShrink: 0,
}}
>
{cfg.label}
</Typography>
{isActive ? (
<Tooltip title="Stop agent" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onStop(agent.id); }}
sx={{ p: 0.25, color: c.status.error, '&:hover': { bgcolor: `${c.status.error}15` } }}
>
<StopCircleOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Dismiss" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onDismiss(agent.id); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: `${c.text.ghost}15` } }}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
};
const GlobalApprovalOverlay: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const sessions = useAppSelector((state) => state.agents.sessions);
const history = useAppSelector((state) => state.agents.history);
const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds);
const [collapsed, setCollapsed] = useState(false);
const groups: SessionApprovalGroup[] = useMemo(() => {
const result: SessionApprovalGroup[] = [];
for (const [sessionId, session] of Object.entries(sessions)) {
if (session.pending_approvals.length > 0) {
if (session.pending_approvals?.length > 0) {
result.push({
sessionId,
sessionName: session.name || 'Agent',
@@ -42,11 +171,38 @@ const GlobalApprovalOverlay: React.FC = () => {
[groups],
);
const trackedAgents: TrackedAgent[] = useMemo(() => {
return trackedIds
.map((id): TrackedAgent | null => {
const session = sessions[id];
if (session && session.status !== 'draft') {
return { id, name: session.name, status: session.status, dashboardId: session.dashboard_id };
}
const hist: HistorySession | undefined = history[id];
if (hist) {
return { id, name: hist.name, status: hist.status, dashboardId: hist.dashboard_id };
}
return null;
})
.filter((a): a is TrackedAgent => a !== null);
}, [trackedIds, sessions, history]);
const activeAgents = useMemo(
() => trackedAgents.filter((a) => a.status === 'running' || a.status === 'waiting_approval'),
[trackedAgents],
);
const finishedAgents = useMemo(
() => trackedAgents.filter((a) => a.status !== 'running' && a.status !== 'waiting_approval'),
[trackedAgents],
);
const totalBadge = totalApprovals + activeAgents.length;
useEffect(() => {
if (totalApprovals > 0) {
if (totalApprovals > 0 || activeAgents.length > 0) {
setCollapsed(false);
}
}, [totalApprovals]);
}, [totalApprovals, activeAgents.length]);
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
@@ -62,7 +218,39 @@ const GlobalApprovalOverlay: React.FC = () => {
[dispatch],
);
if (totalApprovals === 0) return null;
const onStopAgent = useCallback(
(sessionId: string) => {
dispatch(stopAgent({ sessionId }));
},
[dispatch],
);
const onDismissAgent = useCallback(
(sessionId: string) => {
dispatch(dismissAgentNotification(sessionId));
},
[dispatch],
);
const onNavigateToDashboard = useCallback(
(dashboardId: string, agentId: string) => {
dispatch(setPendingFocusAgentId(agentId));
navigate(`/dashboard/${dashboardId}`);
},
[navigate, dispatch],
);
if (totalApprovals === 0 && trackedAgents.length === 0) return null;
const hasApprovals = totalApprovals > 0;
const hasAgents = trackedAgents.length > 0;
const headerTitle = hasApprovals && !hasAgents
? 'Approval Required'
: hasAgents && !hasApprovals
? 'Agents'
: 'Notifications';
const headerColor = hasApprovals ? c.status.warning : c.status.info;
const headerBg = hasApprovals ? c.status.warningBg : c.status.infoBg;
return (
<Box
@@ -78,8 +266,8 @@ const GlobalApprovalOverlay: React.FC = () => {
flexDirection: 'column',
borderRadius: `${c.radius.xl}px`,
bgcolor: c.bg.surface,
border: `1px solid ${c.status.warning}40`,
boxShadow: `0 8px 32px rgba(0,0,0,0.25), 0 0 0 1px ${c.status.warning}20`,
border: `1px solid ${headerColor}40`,
boxShadow: `0 8px 32px rgba(0,0,0,0.25), 0 0 0 1px ${headerColor}20`,
overflow: 'hidden',
animation: 'approvalSlideIn 0.25s ease-out',
'@keyframes approvalSlideIn': {
@@ -97,19 +285,19 @@ const GlobalApprovalOverlay: React.FC = () => {
gap: 1,
px: 2,
py: 1.25,
bgcolor: c.status.warningBg,
borderBottom: collapsed ? 'none' : `1px solid ${c.status.warning}20`,
bgcolor: headerBg,
borderBottom: collapsed ? 'none' : `1px solid ${headerColor}20`,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { bgcolor: `${c.status.warning}18` },
'&:hover': { bgcolor: `${headerColor}18` },
transition: 'background-color 0.15s',
}}
>
<NotificationsActiveIcon
sx={{
fontSize: 18,
color: c.status.warning,
animation: 'approvalBell 0.6s ease-in-out',
color: headerColor,
animation: hasApprovals ? 'approvalBell 0.6s ease-in-out' : 'none',
'@keyframes approvalBell': {
'0%': { transform: 'rotate(0)' },
'20%': { transform: 'rotate(12deg)' },
@@ -120,22 +308,24 @@ const GlobalApprovalOverlay: React.FC = () => {
},
}}
/>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.status.warning, flex: 1 }}>
Approval Required
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: headerColor, flex: 1 }}>
{headerTitle}
</Typography>
<Chip
label={totalApprovals}
size="small"
sx={{
height: 22,
minWidth: 28,
fontSize: '0.75rem',
fontWeight: 700,
bgcolor: `${c.status.warning}20`,
color: c.status.warning,
border: 'none',
}}
/>
{totalBadge > 0 && (
<Chip
label={totalBadge}
size="small"
sx={{
height: 22,
minWidth: 28,
fontSize: '0.75rem',
fontWeight: 700,
bgcolor: `${headerColor}20`,
color: headerColor,
border: 'none',
}}
/>
)}
<IconButton size="small" sx={{ color: c.text.ghost, p: 0.25 }}>
{collapsed ? <ExpandMoreIcon sx={{ fontSize: 18 }} /> : <ExpandLessIcon sx={{ fontSize: 18 }} />}
</IconButton>
@@ -146,7 +336,6 @@ const GlobalApprovalOverlay: React.FC = () => {
<Box
sx={{
overflow: 'auto',
py: 1,
maxHeight: 'calc(100vh - 120px)',
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
@@ -159,41 +348,108 @@ const GlobalApprovalOverlay: React.FC = () => {
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{groups.map((group) => (
<Box key={group.sessionId} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
{groups.length > 1 && (
{/* Approvals section */}
{hasApprovals && (
<Box sx={{ py: 1 }}>
{hasAgents && (
<Typography
sx={{
fontSize: '0.7rem',
fontWeight: 600,
color: c.text.muted,
fontSize: '0.65rem',
fontWeight: 700,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
letterSpacing: '0.06em',
px: 2,
py: 0.5,
pb: 0.5,
}}
>
{group.sessionName}
Approvals
</Typography>
)}
{group.approvals.length > 1 ? (
<BatchApprovalBar
requests={group.approvals}
onApprove={onApprove}
onDeny={onDeny}
/>
) : (
group.approvals.map((req) => (
<ApprovalBar
key={req.id}
request={req}
onApprove={onApprove}
onDeny={onDeny}
/>
))
)}
{groups.map((group) => (
<Box key={group.sessionId} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
{groups.length > 1 && (
<Typography
sx={{
fontSize: '0.7rem',
fontWeight: 600,
color: c.text.muted,
textTransform: 'uppercase',
letterSpacing: '0.04em',
px: 2,
py: 0.5,
}}
>
{group.sessionName}
</Typography>
)}
{group.approvals.length > 1 ? (
<BatchApprovalBar
requests={group.approvals}
onApprove={onApprove}
onDeny={onDeny}
/>
) : (
group.approvals.map((req) => (
<ApprovalBar
key={req.id}
request={req}
onApprove={onApprove}
onDeny={onDeny}
/>
))
)}
</Box>
))}
</Box>
))}
)}
{/* Divider between sections */}
{hasApprovals && hasAgents && (
<Box sx={{ mx: 2, borderTop: `1px solid ${c.border.light}` }} />
)}
{/* Agent status section */}
{hasAgents && (
<Box sx={{ py: 1 }}>
{hasApprovals && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 700,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
pt: 0.5,
}}
>
Agents
</Typography>
)}
{activeAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</Box>
)}
</Box>
)}
</Box>
@@ -11,6 +11,7 @@ const shortcuts = [
{ key: 't', description: 'Go to Templates' },
{ key: '1-9', description: 'Open agent by position' },
{ key: '⌘M', description: 'Add App' },
{ key: '⌘N', description: 'New Browser' },
{ key: '⌘O', description: 'History' },
{ key: 'Shift+A', description: 'Approve all pending' },
{ key: 'Shift+D', description: 'Deny all pending' },
+240 -23
View File
@@ -28,17 +28,24 @@ import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 220;
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
const CUSTOMIZATION_ITEMS = [
{ label: 'Prompts', path: '/templates', icon: <DescriptionIcon /> },
@@ -76,10 +83,34 @@ const AppShell: React.FC = () => {
const updateStatus = useAppSelector((state) => state.update.status);
const availableVersion = useAppSelector((state) => state.update.availableVersion);
const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false);
const downloadPercent = useAppSelector((state) => state.update.downloadPercent);
const showUpdateDot = updateStatus === 'available' || updateStatus === 'downloaded';
const showUpdateBanner = updateStatus === 'downloaded' && !updateBannerDismissed;
const [dismissedVersion, setDismissedVersion] = useState<string | null>(() => {
try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; }
});
const [snackbarDismissed, setSnackbarDismissed] = useState(false);
const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading';
const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion;
const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion;
const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed;
const handleDismissBanner = useCallback(() => {
if (availableVersion) {
try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {}
setDismissedVersion(availableVersion);
}
}, [availableVersion]);
const handleDownloadUpdate = useCallback(async () => {
try { await (window as any).openswarm?.downloadUpdate(); } catch {}
}, []);
const handleInstallUpdate = useCallback(() => {
(window as any).openswarm?.installUpdate();
}, []);
const dashboardItems = useAppSelector((state) => state.dashboards.items);
const dashboardList = Object.values(dashboardItems).sort(
@@ -96,6 +127,75 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => {
const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
if (dashMatch) {
if (webContentsId != null) {
const browserId = findBrowserByWebContentsId(webContentsId);
if (browserId) {
dispatch(addBrowserTab({ browserId, url, makeActive: true }));
return;
}
}
dispatch(addBrowserCard({ url }));
} else {
dispatch(setPendingBrowserUrl(url));
const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined;
const firstDashboard = dashboardList[0];
const targetId = lastId || firstDashboard?.id;
if (targetId) {
navigate(`/dashboard/${targetId}`);
} else {
dispatch(createDashboard('Untitled Dashboard')).then((result: any) => {
if (createDashboard.fulfilled.match(result)) {
navigate(`/dashboard/${result.payload.id}`);
}
});
}
}
}, [location.pathname, dashboardList, dispatch, navigate]);
useEffect(() => {
let lastUrl = '';
let lastTime = 0;
const handleClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement)?.closest?.('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
if (!/^https?:\/\//i.test(href)) return;
if (href.startsWith('http://localhost:')) return;
e.preventDefault();
e.stopPropagation();
const now = Date.now();
if (href === lastUrl && now - lastTime < 1000) return;
lastUrl = href;
lastTime = now;
openUrlInBrowser(href);
};
document.addEventListener('click', handleClick, true);
return () => document.removeEventListener('click', handleClick, true);
}, [openUrlInBrowser]);
useEffect(() => {
const w = window as any;
if (!w.openswarm?.onWebviewNewWindow) return;
let lastUrl = '';
let lastTime = 0;
return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => {
const now = Date.now();
if (url === lastUrl && now - lastTime < 1000) return;
lastUrl = url;
lastTime = now;
openUrlInBrowser(url, webContentsId);
});
}, [openUrlInBrowser]);
useEffect(() => {
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
}, [sidebarWidth]);
@@ -281,6 +381,98 @@ const AppShell: React.FC = () => {
</Box>
</Box>
{showUpdateBanner && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 0.5,
bgcolor: `${c.accent.primary}14`,
borderBottom: `1px solid ${c.accent.primary}30`,
flexShrink: 0,
}}
>
<SystemUpdateAltIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
</Typography>
{updateStatus === 'downloading' && (
<LinearProgress
variant="determinate"
value={downloadPercent}
sx={{
width: 120,
height: 3,
flexShrink: 0,
borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}}
/>
)}
{updateStatus === 'downloading' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, flexShrink: 0 }}>
{Math.round(downloadPercent)}%
</Typography>
)}
{updateStatus === 'available' && (
<Button
size="small"
variant="contained"
onClick={handleDownloadUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: 1.5,
minWidth: 'auto',
py: 0.25,
px: 1.5,
lineHeight: 1.5,
flexShrink: 0,
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
size="small"
variant="contained"
onClick={handleInstallUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: 1.5,
minWidth: 'auto',
py: 0.25,
px: 1.5,
lineHeight: 1.5,
flexShrink: 0,
}}
>
Restart & Update
</Button>
)}
<IconButton
size="small"
onClick={handleDismissBanner}
sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0, '&:hover': { color: c.text.secondary } }}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
{!sidebarCollapsed && (
<>
@@ -769,36 +961,60 @@ const AppShell: React.FC = () => {
<GlobalApprovalOverlay />
<Snackbar
open={showUpdateBanner}
open={showUpdateSnackbar}
autoHideDuration={10000}
onClose={() => setSnackbarDismissed(true)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
icon={<RestartAltIcon sx={{ fontSize: 18 }} />}
icon={updateStatus === 'downloaded'
? <RestartAltIcon sx={{ fontSize: 18 }} />
: <SystemUpdateAltIcon sx={{ fontSize: 18 }} />
}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
size="small"
onClick={() => setUpdateBannerDismissed(true)}
onClick={() => setSnackbarDismissed(true)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}
>
Later
</Button>
<Button
size="small"
variant="contained"
onClick={() => (window as any).openswarm?.installUpdate()}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Restart
Dismiss
</Button>
{updateStatus === 'available' && (
<Button
size="small"
variant="contained"
onClick={handleDownloadUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
size="small"
variant="contained"
onClick={handleInstallUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Restart & Update
</Button>
)}
</Box>
}
sx={{
@@ -809,7 +1025,8 @@ const AppShell: React.FC = () => {
'& .MuiAlert-icon': { color: c.accent.primary },
}}
>
OpenSwarm {availableVersion} downloaded restart to update
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded — restart to update`}
</Alert>
</Snackbar>
</Box>
@@ -120,6 +120,7 @@ export function useDomElementSelector(): DomSelectorState {
const dragOriginRef = useRef<{ x: number; y: number } | null>(null);
const isDraggingRef = useRef(false);
const dragBoundsRef = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
const preDragFocusRef = useRef<HTMLElement | null>(null);
const excludeIdRef = useRef<string | null>(null);
useEffect(() => {
@@ -245,8 +246,11 @@ export function useDomElementSelector(): DomSelectorState {
if (e.button !== 0) return;
if (e.metaKey || e.ctrlKey) return;
const target = e.target as Element;
// Only start drag on "empty" canvas areas (not on selectable elements)
if (target && findSelectableAncestor(target, excludeIdRef.current)) return;
if (target && findSelectableAncestor(target, excludeIdRef.current)) {
e.preventDefault();
return;
}
preDragFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
}, []);
@@ -291,12 +295,17 @@ export function useDomElementSelector(): DomSelectorState {
});
}
const wasDragging = isDraggingRef.current;
dragOriginRef.current = null;
isDraggingRef.current = false;
dragBoundsRef.current = null;
setDragRect(EMPTY_DRAG);
setDragPreview([]);
if (dragPreviewRafRef.current) cancelAnimationFrame(dragPreviewRafRef.current);
if (wasDragging && preDragFocusRef.current) {
preDragFocusRef.current.focus();
}
preDragFocusRef.current = null;
}, [ctx]);
const handleClick = useCallback((e: MouseEvent) => {
@@ -327,6 +336,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
preDragFocusRef.current = null;
return;
}
@@ -353,6 +363,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
preDragFocusRef.current = null;
};
}, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]);
+551 -65
View File
@@ -5,8 +5,16 @@ import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import TextField from '@mui/material/TextField';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckIcon from '@mui/icons-material/Check';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
sendMessage as sendMessageThunk,
@@ -17,6 +25,8 @@ import {
handleApproval,
editMessage,
switchBranch,
duplicateSession,
setActiveSession,
updateSessionModel,
updateSessionMode,
fetchSession,
@@ -25,12 +35,12 @@ import {
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs } from '@/shared/ws/WebSocketManager';
import MessageBubble from './MessageBubble';
import MessageActionBar from './MessageActionBar';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput, { ChatInputHandle } from './ChatInput';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import BranchNavigator from './BranchNavigator';
import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -91,14 +101,27 @@ const ThinkingBubble: React.FC = () => {
);
};
interface QueuedMessage {
prompt: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
selectedBrowserIds?: string[];
}
interface AgentChatProps {
sessionId?: string;
onClose?: () => void;
embedded?: boolean;
autoFocus?: boolean;
isGlowing?: boolean;
onDismissGlow?: () => void;
initialContextPaths?: ContextPath[];
onBranch?: (newSessionId: string) => void;
}
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, initialContextPaths }) => {
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => {
const c = useClaudeTokens();
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
running: { color: c.status.success, bg: c.status.successBg },
@@ -116,11 +139,20 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
const [awaitingResponse, setAwaitingResponse] = useState(false);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
const initialContextApplied = useRef(false);
const messageQueueRef = useRef<QueuedMessage[]>([]);
const [queueLength, setQueueLength] = useState(0);
const [queueExpanded, setQueueExpanded] = useState(false);
const [editingQueueIdx, setEditingQueueIdx] = useState<number | null>(null);
const [editingQueueText, setEditingQueueText] = useState('');
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<number | null>(null);
const isDraft = session?.status === 'draft';
@@ -157,13 +189,64 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (Object.keys(modesMap).length === 0) dispatch(fetchModes());
}, [dispatch, modesMap]);
const dispatchMessage = useCallback((msg: QueuedMessage) => {
if (!id) return;
setShowResumeBubble(false);
setAwaitingResponse(true);
if (isDraft) {
const config: Record<string, any> = { model, mode };
if (session?.system_prompt) config.system_prompt = session.system_prompt;
if (session?.target_directory) config.target_directory = session.target_directory;
dispatch(
launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt }));
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId }));
}
}
});
} else {
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
.then((action) => {
if (sendMessageThunk.rejected.match(action)) {
setAwaitingResponse(false);
}
});
}
}, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]);
const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval'));
const prevStatusRef = useRef(session?.status);
useEffect(() => {
const prev = prevStatusRef.current;
const curr = session?.status;
prevStatusRef.current = curr;
if (prev === 'running' && (curr === 'completed' || curr === 'stopped' || curr === 'error')) {
let didDispatchQueued = false;
const wasActive = prev === 'running' || prev === 'waiting_approval';
const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error';
if (wasActive && isTerminal) {
if (id) dispatch(clearGlowingBrowserCards(id));
const nextQueued = messageQueueRef.current.shift();
if (nextQueued) {
setQueueLength(messageQueueRef.current.length);
dispatchMessage(nextQueued);
didDispatchQueued = true;
} else {
if (curr === 'stopped') {
setShowResumeBubble(true);
}
}
const currentMode = modesMap[mode];
if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) {
setMode(currentMode.default_next_mode);
@@ -172,7 +255,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
}
}
}, [session?.status, mode, modesMap, id, isDraft, dispatch]);
if (curr === 'running') {
setShowResumeBubble(false);
}
if (curr !== 'draft' && !didDispatchQueued) {
setAwaitingResponse(false);
}
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]);
const SCROLL_THRESHOLD = 50;
@@ -201,27 +290,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => {
if (!id) return;
if (isDraft) {
const config: Record<string, any> = { model, mode };
if (session?.system_prompt) config.system_prompt = session.system_prompt;
if (session?.target_directory) config.target_directory = session.target_directory;
dispatch(
launchAndSendFirstMessage({ draftId: id, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt }));
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId }));
}
}
});
} else {
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: id }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }));
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds };
if (agentBusy) {
messageQueueRef.current.push(msg);
setQueueLength(messageQueueRef.current.length);
return;
}
dispatchMessage(msg);
};
const handleModeChange = useCallback((newMode: string) => {
@@ -247,14 +322,33 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
dispatch(stopAgent({ sessionId: id }));
};
const handleEdit = useCallback(
const handleResume = useCallback(() => {
if (!id) return;
setShowResumeBubble(false);
dispatch(sendMessageThunk({
sessionId: id,
prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off",
mode,
model,
hidden: true,
}));
}, [id, mode, model, dispatch]);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const handleSaveEdit = useCallback(
(messageId: string, newContent: string) => {
if (!id) return;
dispatch(editMessage({ sessionId: id, messageId, content: newContent }));
setEditingMessageId(null);
},
[id, dispatch]
);
const handleCancelEdit = useCallback(() => {
setEditingMessageId(null);
}, []);
const activeBranchMessages = useMemo(() => {
if (!session) return [];
const branchId = session.active_branch_id || 'main';
@@ -264,14 +358,74 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId);
}
const forkIdx = session.messages.findIndex((m) => m.id === branch.fork_point_message_id);
const preMessages = session.messages
.slice(0, forkIdx)
.filter((m) => m.branch_id === (branch.parent_branch_id || 'main'));
const branchMessages = session.messages.filter((m) => m.branch_id === branchId);
return [...preMessages, ...branchMessages];
const segments: Array<{ branchId: string; upToMessageId?: string }> = [];
let cur = branch;
let curId = branchId;
while (cur && cur.fork_point_message_id) {
segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id });
curId = cur.parent_branch_id || 'main';
cur = session.branches?.[curId];
}
segments.unshift({ branchId: curId });
const result: typeof session.messages = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const nextForkMsgId = seg.upToMessageId;
if (nextForkMsgId) {
const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId);
const pre = session.messages
.slice(0, forkIdx)
.filter((m) => m.branch_id === seg.branchId);
result.push(...pre);
} else if (i < segments.length - 1) {
const nextFork = segments[i + 1].upToMessageId;
const forkIdx = nextFork
? session.messages.findIndex((m) => m.id === nextFork)
: session.messages.length;
result.push(
...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId)
);
} else {
result.push(...session.messages.filter((m) => m.branch_id === seg.branchId));
}
}
const leafMsgs = session.messages.filter((m) => m.branch_id === branchId);
if (!result.some((m) => m.branch_id === branchId)) {
result.push(...leafMsgs);
}
return result;
}, [session?.messages, session?.active_branch_id, session?.branches]);
const handleRegenerate = useCallback(
(assistantMsg: AgentMessage) => {
if (!id) return;
const idx = activeBranchMessages.findIndex((m) => m.id === assistantMsg.id);
for (let i = idx - 1; i >= 0; i--) {
if (activeBranchMessages[i].role === 'user') {
const userMsg = activeBranchMessages[i];
const content = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
dispatch(editMessage({ sessionId: id, messageId: userMsg.id, content }));
break;
}
}
},
[id, activeBranchMessages, dispatch]
);
const handleBranchChat = useCallback(async (upToMessageId: string) => {
if (!id) return;
const dashId = session?.dashboard_id;
const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId }));
if (duplicateSession.fulfilled.match(action)) {
if (onBranch) {
onBranch(action.payload.id);
} else {
dispatch(setActiveSession(action.payload.id));
}
}
}, [id, dispatch, onBranch, session?.dashboard_id]);
const contextEstimate = useMemo(() => {
const limit = CONTEXT_WINDOWS[model] || 200_000;
let totalChars = 0;
@@ -373,13 +527,33 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
items.push(...outputItems);
} else {
items.push(msg);
if (!msg.hidden) {
items.push(msg);
}
i++;
}
}
return items;
}, [activeBranchMessages]);
const lastAssistantIdsInTurn = useMemo(() => {
const ids = new Set<string>();
let lastAssistantId: string | null = null;
for (const item of renderItems) {
if (!isToolGroup(item) && !isToolPair(item)) {
const msg = item as AgentMessage;
if (msg.role === 'assistant') {
lastAssistantId = msg.id;
} else if (msg.role === 'user') {
if (lastAssistantId) ids.add(lastAssistantId);
lastAssistantId = null;
}
}
}
if (lastAssistantId) ids.add(lastAssistantId);
return ids;
}, [renderItems]);
const groupMetaRequestedRef = useRef<Set<string>>(new Set());
const groupMetaRefinedRef = useRef<Set<string>>(new Set());
@@ -427,11 +601,33 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const getSiblingBranches = useCallback(
(messageId: string): string[] => {
if (!session?.branches) return [];
return Object.values(session.branches)
const directForks = Object.values(session.branches)
.filter((b) => b.fork_point_message_id === messageId)
.map((b) => b.id);
if (directForks.length > 0) {
const originalMsg = session.messages.find((m) => m.id === messageId);
const parentBranchId = originalMsg?.branch_id || 'main';
return [parentBranchId, ...directForks];
}
const msg = session.messages.find((m) => m.id === messageId);
if (!msg || msg.role !== 'user') return [];
const msgBranch = session.branches[msg.branch_id];
if (!msgBranch?.fork_point_message_id) return [];
const branchUserMsgs = session.messages.filter(
(m) => m.branch_id === msg.branch_id && m.role === 'user'
);
if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return [];
const forkPointId = msgBranch.fork_point_message_id;
const siblingBranches = Object.values(session.branches)
.filter((b) => b.fork_point_message_id === forkPointId)
.map((b) => b.id);
const parentBranchId = msgBranch.parent_branch_id || 'main';
return [parentBranchId, ...siblingBranches];
},
[session?.branches]
[session?.branches, session?.messages]
);
if (!session) {
@@ -527,37 +723,55 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
{renderItems.map((item) => {
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
return <ToolGroupBubble key={item.id} group={item} isSessionRunning={sessionRunning} meta={groupMeta} />;
return <ToolGroupBubble key={item.id} group={item} isSessionRunning={sessionRunning} meta={groupMeta} sessionId={session.id} />;
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
return <ToolCallBubble key={item.id} call={item.call} result={item.result} isPending={isPending} />;
return <ToolCallBubble key={item.id} call={item.call} result={item.result} isPending={isPending} sessionId={session.id} />;
}
const msg = item;
const isEditing = editingMessageId === msg.id;
const siblings = getSiblingBranches(msg.id);
const hasBranches = siblings.length > 0;
const currentBranchIdx = hasBranches
? siblings.indexOf(session.active_branch_id || 'main')
: 0;
const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
<React.Fragment key={msg.id}>
<MessageBubble message={msg} onEdit={msg.role === 'user' ? handleEdit : undefined} />
{hasBranches && (
<BranchNavigator
currentIndex={Math.max(0, currentBranchIdx)}
totalBranches={siblings.length}
onPrevious={() => {
const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)];
if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch }));
}}
onNext={() => {
const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)];
if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch }));
}}
<Box key={msg.id} sx={{ '&:hover .msg-actions': { opacity: 1 } }}>
<MessageBubble
message={msg}
editing={isEditing}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
/>
{!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && (
<MessageActionBar
role={msg.role as 'user' | 'assistant'}
onCopy={() => navigator.clipboard.writeText(rawText)}
onEdit={msg.role === 'user' ? () => setEditingMessageId(msg.id) : undefined}
onRegenerate={msg.role === 'assistant' ? () => handleRegenerate(msg) : undefined}
onBranch={msg.role === 'assistant' ? () => handleBranchChat(msg.id) : undefined}
branchNav={
hasBranches
? {
currentIndex: Math.max(0, currentBranchIdx),
totalBranches: siblings.length,
onPrevious: () => {
const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)];
if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch }));
},
onNext: () => {
const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)];
if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch }));
},
}
: undefined
}
/>
)}
</React.Fragment>
</Box>
);
})}
{session.streamingMessage && (
@@ -566,6 +780,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
key={`streaming-${session.streamingMessage.id}`}
isStreaming
isPending
sessionId={session.id}
call={{
id: session.streamingMessage.id,
role: 'tool_call',
@@ -590,9 +805,37 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
/>
)
)}
{session.status === 'running' && !session.streamingMessage && (
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && (
<ThinkingBubble />
)}
{showResumeBubble && session.status === 'stopped' && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
onClick={handleResume}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: '12px',
cursor: 'pointer',
bgcolor: `${c.accent.primary}10`,
border: `1px solid ${c.accent.primary}30`,
transition: 'all 0.15s',
'&:hover': {
bgcolor: `${c.accent.primary}1a`,
border: `1px solid ${c.accent.primary}50`,
},
}}
>
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>
Resume Agent Response
</Typography>
</Box>
</Box>
)}
</Box>
{showScrollButton && (
<Tooltip title="Scroll to bottom">
@@ -627,19 +870,262 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
))
)}
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={false}
mode={mode}
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
isRunning={!isDraft && (session.status === 'running' || session.status === 'waiting_approval')}
onStop={handleStop}
contextEstimate={contextEstimate}
sessionId={id}
/>
{isGlowing ? (
<Box
onClick={(e) => { e.stopPropagation(); onDismissGlow?.(); }}
sx={{
mx: 1.5,
mb: 1.5,
py: 1.25,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 2.5,
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.85rem',
color: c.accent.primary,
border: `1.5px solid ${c.accent.primary}`,
background: `${c.accent.primary}08`,
boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`,
animation: 'continue-chat-glow 2s ease-in-out infinite',
transition: 'background 0.15s, box-shadow 0.15s',
'@keyframes continue-chat-glow': {
'0%, 100%': {
boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`,
},
'50%': {
boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15`,
},
},
'&:hover': {
background: `${c.accent.primary}14`,
boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18`,
},
}}
>
Continue chat
</Box>
) : (
<ClickAwayListener onClickAway={() => { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}>
<Box>
{queueLength > 0 && (
<Box sx={{ ml: 3, mr: 1.5 }}>
<Box
onClick={() => { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.25,
borderRadius: '8px 8px 0 0',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderBottom: 'none',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background 0.12s',
}}
>
{queueExpanded
? <KeyboardArrowDownIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
: <KeyboardArrowUpIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: c.text.muted, letterSpacing: 0.2 }}>
{queueLength} queued
</Typography>
<Tooltip title="Clear all">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }}
sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 10 }} />
</IconButton>
</Tooltip>
</Box>
{queueExpanded && (
<Box
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderBottom: 'none',
borderRadius: '0 8px 0 0',
maxHeight: 240,
overflowY: 'auto',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}
>
{messageQueueRef.current.map((msg, idx) => (
<Box
key={idx}
draggable={editingQueueIdx !== idx}
onDragStart={(e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx);
}}
onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }}
onDrop={(e) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== idx) {
const q = messageQueueRef.current;
const [item] = q.splice(dragIdx, 1);
q.splice(idx, 0, item);
setQueueLength(q.length);
}
setDragIdx(null);
setDropTargetIdx(null);
}}
onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 0.75,
px: 1.5,
py: 1,
borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background 0.1s, opacity 0.15s',
...(dragIdx === idx ? { opacity: 0.35 } : {}),
...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx
? { borderTop: `2px solid ${c.accent.primary}` }
: {}),
}}
>
<Box
sx={{
cursor: editingQueueIdx === idx ? 'default' : 'grab',
display: 'flex',
alignItems: 'center',
mt: 0.3,
color: c.text.ghost,
'&:hover': { color: c.text.tertiary },
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon sx={{ fontSize: 14 }} />
</Box>
{editingQueueIdx === idx ? (
<Box sx={{ flex: 1, display: 'flex', gap: 0.5, alignItems: 'flex-start' }}>
<TextField
multiline
fullWidth
size="small"
value={editingQueueText}
onChange={(e) => setEditingQueueText(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const trimmed = editingQueueText.trim();
if (trimmed) {
messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
setQueueLength(messageQueueRef.current.length);
}
setEditingQueueIdx(null);
}
if (e.key === 'Escape') setEditingQueueIdx(null);
}}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.78rem',
color: c.text.primary,
'& fieldset': { borderColor: c.border.medium },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
<IconButton
size="small"
onClick={() => {
const trimmed = editingQueueText.trim();
if (trimmed) {
messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
setQueueLength(messageQueueRef.current.length);
}
setEditingQueueIdx(null);
}}
sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }}
>
<CheckIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
) : (
<Typography
sx={{
flex: 1,
fontSize: '0.78rem',
color: c.text.secondary,
lineHeight: 1.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
{msg.prompt}
</Typography>
)}
{editingQueueIdx !== idx && (
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0, mt: 0.15 }}>
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }}
sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<EditOutlinedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove">
<IconButton
size="small"
onClick={() => {
messageQueueRef.current.splice(idx, 1);
setQueueLength(messageQueueRef.current.length);
if (messageQueueRef.current.length === 0) setQueueExpanded(false);
}}
sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<DeleteOutlineIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={false}
mode={mode}
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
isRunning={agentBusy}
onStop={handleStop}
queueLength={queueLength}
contextEstimate={contextEstimate}
sessionId={id}
autoFocus={autoFocus}
/>
</Box>
</ClickAwayListener>
)}
</Box>
</Box>
);
@@ -21,31 +21,38 @@ const BranchNavigator: React.FC<Props> = ({ currentIndex, totalBranches, onPrevi
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
my: 0.25,
justifyContent: 'flex-end',
mt: -0.25,
mb: 0.5,
}}
>
<IconButton
size="small"
onClick={onPrevious}
disabled={currentIndex === 0}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
}}
>
<ChevronLeftIcon sx={{ fontSize: 18 }} />
</IconButton>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 32, textAlign: 'center' }}>
{currentIndex + 1}/{totalBranches}
</Typography>
<IconButton
size="small"
onClick={onNext}
disabled={currentIndex === totalBranches - 1}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronRightIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
size="small"
onClick={onPrevious}
disabled={currentIndex === 0}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 28, textAlign: 'center', userSelect: 'none' }}>
{currentIndex + 1} / {totalBranches}
</Typography>
<IconButton
size="small"
onClick={onNext}
disabled={currentIndex === totalBranches - 1}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
@@ -0,0 +1,403 @@
import React, { useEffect, useRef, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import LanguageIcon from '@mui/icons-material/Language';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined';
import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined';
import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined';
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined';
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { AgentMessage, AgentSession, fetchBrowserAgentChildren } from '@/shared/state/agentsSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import type { RootState } from '@/shared/state/store';
interface Props {
parentSessionId: string;
browserId?: string;
}
interface FeedEntry {
type: 'thought' | 'action' | 'result' | 'system';
text: string;
actionTool?: string;
sessionLabel?: string;
}
function formatMessage(msg: AgentMessage): FeedEntry | null {
if (msg.role === 'user') return null;
if (msg.role === 'assistant' && typeof msg.content === 'string') {
const trimmed = msg.content.trim();
if (!trimmed) return null;
return { type: 'thought', text: trimmed };
}
if (msg.role === 'tool_call') {
const content =
typeof msg.content === 'string'
? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })()
: msg.content;
const tool = content?.tool || content?.name || '?';
const input = content?.input || {};
let brief = '';
switch (tool) {
case 'BrowserNavigate':
brief = `Navigate → ${input.url || '...'}`;
break;
case 'BrowserClick':
brief = `Click ${input.selector || '...'}`;
break;
case 'BrowserType': {
const txt = (input.text || '').slice(0, 40);
const ellipsis = (input.text || '').length > 40 ? '…' : '';
brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`;
break;
}
case 'BrowserScreenshot':
brief = 'Screenshot';
break;
case 'BrowserGetText':
brief = 'Read page text';
break;
case 'BrowserGetElements':
brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`;
break;
case 'BrowserEvaluate':
brief = `Evaluate JS`;
break;
default:
brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`;
}
return { type: 'action', text: brief, actionTool: tool };
}
if (msg.role === 'tool_result') {
const content =
typeof msg.content === 'string'
? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })()
: msg.content;
const toolName = content?.tool_name || '';
const elapsed = content?.elapsed_ms;
const text = content?.text || '';
if (toolName === 'BrowserScreenshot') {
return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` };
}
const preview = text.length > 120 ? text.slice(0, 120) + '…' : text;
return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` };
}
if (msg.role === 'system') {
return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' };
}
return null;
}
type SvgIconComponent = typeof OpenInNewIcon;
function getActionIcon(tool?: string): SvgIconComponent {
switch (tool) {
case 'BrowserNavigate': return OpenInNewIcon;
case 'BrowserClick': return TouchAppOutlinedIcon;
case 'BrowserType': return KeyboardOutlinedIcon;
case 'BrowserScreenshot': return CameraAltOutlinedIcon;
case 'BrowserGetText': return ArticleOutlinedIcon;
case 'BrowserGetElements': return AccountTreeOutlinedIcon;
case 'BrowserEvaluate': return CodeOutlinedIcon;
default: return BuildOutlinedIcon;
}
}
interface FeedColors {
thought: string;
thoughtIcon: string;
result: string;
error: string;
errorIcon: string;
scrollThumb: string;
}
const darkFeedColors: FeedColors = {
thought: '#a0aab8',
thoughtIcon: '#555b6e',
result: '#555b6e',
error: '#ff8787',
errorIcon: '#ff8787',
scrollThumb: '#2a2d3e',
};
const lightFeedColors: FeedColors = {
thought: '#555550',
thoughtIcon: '#9e9c95',
result: '#9e9c95',
error: '#c03030',
errorIcon: '#c03030',
scrollThumb: '#ccc9c0',
};
const selectBrowserSessions = createSelector(
[(state: RootState) => state.agents.sessions,
(_: RootState, parentSessionId: string) => parentSessionId,
(_: RootState, __: string, browserId?: string) => browserId],
(sessions, parentSessionId, browserId) =>
Object.values(sessions).filter(
(s): s is AgentSession =>
s.mode === 'browser-agent' &&
s.parent_session_id === parentSessionId &&
(!browserId || s.browser_id === browserId),
),
);
const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { mode } = useThemeMode();
const fc = mode === 'dark' ? darkFeedColors : lightFeedColors;
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(null);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
useEffect(() => {
if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
fetchedForSession.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
const sessionsWithEntries = useMemo(() => {
return browserSessions.map((session) => {
const entries: FeedEntry[] = [];
for (const msg of session.messages) {
const entry = formatMessage(msg);
if (entry) entries.push(entry);
}
if (session.streamingMessage?.role === 'assistant' && session.streamingMessage.content) {
entries.push({ type: 'thought', text: session.streamingMessage.content });
}
return { session, entries };
});
}, [browserSessions]);
const totalMessages = browserSessions.reduce(
(n, s) => n + s.messages.length + (s.streamingMessage ? 1 : 0),
0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [totalMessages]);
if (browserSessions.length === 0) return null;
const showLabels = sessionsWithEntries.length > 1;
const accentColor = c.accent.primary;
return (
<Box
ref={scrollRef}
sx={{
maxHeight: 300,
overflowY: 'auto',
px: 1.5,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.25,
scrollbarWidth: 'thin',
scrollbarColor: `${fc.scrollThumb} transparent`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
background: fc.scrollThumb,
borderRadius: 2,
},
}}
>
{sessionsWithEntries.map(({ session, entries }, si) => (
<Box key={session.id} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{showLabels && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: si > 0 ? 1 : 0, mb: 0.25 }}>
<LanguageIcon sx={{ fontSize: 12, color: accentColor, opacity: 0.7 }} />
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: accentColor,
opacity: 0.8,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{session.browser_id || `Browser ${si + 1}`}
</Typography>
<SessionStatusChip status={session.status} />
</Box>
)}
{!showLabels && entries.length === 0 && session.status === 'running' && (
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.tertiary,
fontStyle: 'italic',
fontFamily: c.font.mono,
}}
>
Starting browser agent...
</Typography>
)}
{entries.map((entry, i) => (
<EntryRow key={i} entry={entry} accentColor={accentColor} fc={fc} />
))}
{!showLabels && session.status === 'running' && entries.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: accentColor,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
</Box>
)}
</Box>
))}
</Box>
);
};
const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
const c = useClaudeTokens();
if (entry.type === 'thought') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<SmartToyOutlinedIcon
sx={{ fontSize: 10, color: fc.thoughtIcon, mt: '3px', flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.7rem',
color: fc.thought,
lineHeight: 1.45,
wordBreak: 'break-word',
fontFamily: c.font.mono,
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'action') {
const ActionIcon = getActionIcon(entry.actionTool);
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<ActionIcon sx={{ fontSize: 11, color: accentColor, mt: '2px', flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.7rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'result') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0, pl: 1.25 }}>
<Typography
sx={{
fontSize: '0.65rem',
fontFamily: c.font.mono,
color: fc.result,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'system') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<ErrorOutlineIcon sx={{ fontSize: 10, color: fc.errorIcon, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.68rem',
fontFamily: c.font.mono,
color: fc.error,
lineHeight: 1.45,
}}
>
{entry.text}
</Typography>
</Box>
);
}
return null;
};
const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
if (status === 'running') {
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.success,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
);
}
if (status === 'completed') {
return <CheckCircleOutlineIcon sx={{ fontSize: 10, color: c.status.success }} />;
}
if (status === 'error') {
return <ErrorOutlineIcon sx={{ fontSize: 10, color: c.status.error }} />;
}
return null;
};
export default React.memo(BrowserAgentInlineFeed);
+159 -97
View File
@@ -26,6 +26,7 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker';
import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext';
import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard';
import { getWebview } from '@/shared/browserRegistry';
import { API_BASE } from '@/shared/config';
import { ContextPath } from '@/app/components/DirectoryBrowser';
@@ -73,6 +74,7 @@ interface Props {
embedded?: boolean;
autoFocus?: boolean;
sessionId?: string;
queueLength?: number;
}
export interface ChatInputHandle {
@@ -130,7 +132,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -138,6 +140,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
const fallbackOwnerIdRef = useRef(`input-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
const ownerId = sessionId || fallbackOwnerIdRef.current;
useEffect(() => {
if (autoFocus) editorRef.current?.focus();
}, [autoFocus]);
@@ -281,7 +286,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
let trimmed = serialized.trim();
if (!trimmed) return;
const selectedEls = elementSelection?.selectedElements ?? [];
const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? [];
let allImages = images.length > 0
? images.map(({ data, media_type }) => ({ data, media_type }))
: [];
@@ -298,7 +303,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
lines.push(`${i + 1}. [Browser Card] ${title}`);
lines.push(` browser_id: ${el.semanticData.selectId}`);
if (url) lines.push(` URL: ${url}`);
lines.push(` (Use BrowserAgent with this browser_id to interact with it)`);
lines.push(` (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)`);
} else if (el.semanticType && el.semanticData) {
const typeLabel = {
'agent-card': 'Agent Card',
@@ -317,6 +322,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(', ');
if (metaStr) lines.push(` ${metaStr}`);
if (el.semanticType === 'agent-card' && selectId) {
lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`);
}
} else {
const styleStr = Object.entries(el.computedStyles)
.map(([k, v]) => `${k}: ${v}`)
@@ -359,8 +367,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
setForcedTools([]);
setAttachedSkills({});
setHasContent(false);
elementSelection?.clearSelectedElements();
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection]);
elementSelection?.clearOwnerElements(ownerId);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]);
const detectTrigger = useCallback(() => {
const result = detectEditorTrigger();
@@ -469,6 +477,41 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const copied = getClipboardCards();
if (copied.length > 0 && elementSelection) {
e.preventDefault();
for (const card of copied) {
const semanticTypeMap: Record<string, SelectedElement['semanticType']> = {
agent: 'agent-card',
view: 'view-card',
browser: 'browser-card',
};
const semanticType = semanticTypeMap[card.type];
if (!semanticType) continue;
const labelMap: Record<string, string> = {
'agent-card': 'Agent',
'view-card': 'View',
'browser-card': 'Browser',
};
const semanticLabel = (labelMap[semanticType] || semanticType) + ': ' + card.name;
const el: SelectedElement = {
id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`,
tagName: 'DIV',
className: '',
outerHTML: '',
computedStyles: {},
boundingRect: { x: 0, y: 0, width: 0, height: 0 },
semanticType,
semanticLabel,
semanticData: { ...card.meta, selectId: card.id },
};
elementSelection.addElementForOwner(ownerId, el);
}
clearClipboard();
return;
}
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
@@ -486,7 +529,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
e.preventDefault();
const plain = e.clipboardData.getData('text/plain');
if (plain) document.execCommand('insertText', false, plain);
}, [addImageFiles]);
}, [addImageFiles, elementSelection, ownerId]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -535,7 +578,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
},
};
const selectedElements = elementSelection?.selectedElements ?? [];
const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? [];
const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0;
return (
@@ -783,7 +826,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
icon={<AdsClickIcon sx={{ fontSize: 14 }} />}
label={chipLabel}
size="small"
onDelete={() => elementSelection?.removeSelectedElement(el.id)}
onDelete={() => elementSelection?.removeOwnerElement(ownerId, el.id)}
sx={{
bgcolor: 'rgba(59, 130, 246, 0.1)',
color: '#3b82f6',
@@ -849,7 +892,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
userSelect: 'none',
}}
>
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : `${modeConf.label}, @ for context, / for commands`}
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
</div>
)}
</Box>
@@ -977,40 +1020,54 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
/>
)}
{elementSelection && !autoRunMode && (
<Tooltip title={elementSelection.selectMode ? 'Exit select mode' : 'Select UI element'}>
<IconButton
size="small"
onClick={() => {
if (!elementSelection.selectMode && sessionId) {
elementSelection.setExcludeSelectId(sessionId);
}
elementSelection.toggleSelectMode();
}}
sx={{
p: 0.5,
...(elementSelection.selectMode
? {
bgcolor: '#3b82f6',
color: '#fff',
'&:hover': { bgcolor: '#2563eb' },
animation: 'selectBtnPulse 2s ease-in-out infinite',
'@keyframes selectBtnPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' },
'50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' },
},
{elementSelection && !autoRunMode && (() => {
const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId;
return (
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
<IconButton
size="small"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (isMySelectMode) {
elementSelection.setSelectMode(false);
} else {
if (elementSelection.activeOwnerId !== ownerId) {
elementSelection.clearOwnerElements(ownerId);
}
: {
color: c.text.tertiary,
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
}),
transition: 'background-color 0.15s, color 0.15s',
}}
>
<AdsClickIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
elementSelection.setActiveOwnerId(ownerId);
if (sessionId) {
elementSelection.setExcludeSelectId(sessionId);
} else {
elementSelection.setExcludeSelectId(null);
}
elementSelection.setSelectMode(true);
}
}}
sx={{
p: 0.5,
...(isMySelectMode
? {
bgcolor: '#3b82f6',
color: '#fff',
'&:hover': { bgcolor: '#2563eb' },
animation: 'selectBtnPulse 2s ease-in-out infinite',
'@keyframes selectBtnPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' },
'50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' },
},
}
: {
color: c.text.tertiary,
'&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' },
}),
transition: 'background-color 0.15s, color 0.15s',
}}
>
<AdsClickIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
);
})()}
<input
ref={generalFileInputRef}
@@ -1040,61 +1097,66 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
<AttachFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{!autoRunMode && (isRunning ? (
<Tooltip title="Stop agent">
<IconButton
size="small"
onClick={onStop}
sx={{
bgcolor: c.status.error,
color: c.text.inverse,
p: 0.5,
width: 26,
height: 26,
'&:hover': { bgcolor: c.status.error, opacity: 0.85 },
transition: c.transition,
}}
>
<StopIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : hasContent ? (
<Tooltip title="Send message">
<IconButton
size="small"
onClick={handleSend}
disabled={disabled}
sx={{
bgcolor: c.accent.primary,
color: c.text.inverse,
p: 0.5,
width: 26,
height: 26,
'&:hover': { bgcolor: c.accent.hover },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
transition: c.transition,
}}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Voice input (coming soon)">
<span>
<IconButton
size="small"
disabled
sx={{
color: c.text.tertiary,
p: 0.5,
'&.Mui-disabled': { color: c.text.ghost },
}}
>
<MicNoneOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
))}
{!autoRunMode && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasContent && (
<Tooltip title={isRunning ? 'Queue message' : 'Send message'}>
<IconButton
size="small"
onClick={handleSend}
disabled={disabled}
sx={{
bgcolor: c.accent.primary,
color: c.text.inverse,
p: 0.5,
width: 26,
height: 26,
'&:hover': { bgcolor: c.accent.hover },
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
transition: c.transition,
}}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{isRunning ? (
<Tooltip title="Stop agent">
<IconButton
size="small"
onClick={onStop}
sx={{
bgcolor: c.status.error,
color: c.text.inverse,
p: 0.5,
width: 26,
height: 26,
'&:hover': { bgcolor: c.status.error, opacity: 0.85 },
transition: c.transition,
}}
>
<StopIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : !hasContent ? (
<Tooltip title="Voice input (coming soon)">
<span>
<IconButton
size="small"
disabled
sx={{
color: c.text.tertiary,
p: 0.5,
'&.Mui-disabled': { color: c.text.ghost },
}}
>
<MicNoneOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
) : null}
</Box>
)}
</Box>
{selectedTemplate && (
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import EditIcon from '@mui/icons-material/Edit';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import ReplayIcon from '@mui/icons-material/Replay';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface BranchNavProps {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
interface Props {
role: 'user' | 'assistant';
onCopy: () => void;
onEdit?: () => void;
onRegenerate?: () => void;
onBranch?: () => void;
branchNav?: BranchNavProps;
}
const btnSx = (c: ReturnType<typeof useClaudeTokens>) => ({
color: c.text.tertiary,
p: 0.4,
'&:hover': { color: c.text.secondary, bgcolor: 'transparent' },
'&.Mui-disabled': { color: c.border.medium },
});
const MessageActionBar: React.FC<Props> = ({
role,
onCopy,
onEdit,
onRegenerate,
onBranch,
branchNav,
}) => {
const c = useClaudeTokens();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
onCopy();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const isUser = role === 'user';
return (
<Box
className="msg-actions"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: isUser ? 'flex-end' : 'flex-start',
gap: 0,
opacity: 0,
transition: 'opacity 0.15s',
mt: -0.25,
mb: 0.25,
minHeight: 28,
}}
>
{isUser ? (
<>
<Tooltip title="Coming soon" arrow>
<span>
<IconButton size="small" disabled sx={btnSx(c)}>
<BookmarkBorderIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onEdit && (
<Tooltip title="Edit" arrow>
<IconButton size="small" onClick={onEdit} sx={btnSx(c)}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{branchNav && branchNav.totalBranches > 1 && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', ml: 0.25 }}>
<IconButton
size="small"
onClick={branchNav.onPrevious}
disabled={branchNav.currentIndex === 0}
sx={btnSx(c)}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
minWidth: 28,
textAlign: 'center',
userSelect: 'none',
}}
>
{branchNav.currentIndex + 1} / {branchNav.totalBranches}
</Typography>
<IconButton
size="small"
onClick={branchNav.onNext}
disabled={branchNav.currentIndex === branchNav.totalBranches - 1}
sx={btnSx(c)}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
)}
</>
) : (
<>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate" arrow>
<IconButton size="small" onClick={onRegenerate} sx={btnSx(c)}>
<ReplayIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{onBranch && (
<Tooltip title="Branch chat" arrow>
<IconButton size="small" onClick={onBranch} sx={btnSx(c)}>
<CallSplitIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</>
)}
</Box>
);
};
export default MessageActionBar;
@@ -8,7 +8,6 @@ import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import Modal from '@mui/material/Modal';
import EditIcon from '@mui/icons-material/Edit';
import CloseIcon from '@mui/icons-material/Close';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -391,13 +390,14 @@ const MessageImageThumbnails: React.FC<{
interface Props {
message: AgentMessage;
onEdit?: (messageId: string, newContent: string) => void;
editing?: boolean;
onSaveEdit?: (messageId: string, newContent: string) => void;
onCancelEdit?: () => void;
isStreaming?: boolean;
}
const MessageBubble: React.FC<Props> = React.memo(({ message, onEdit, isStreaming }) => {
const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => {
const c = useClaudeTokens();
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState('');
const { role, content } = message;
@@ -440,23 +440,22 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, onEdit, isStreamin
? parseElementContext(rawText)
: { userMessage: rawText, elements: [] };
const handleStartEdit = () => {
setEditText(rawText);
setEditing(true);
};
React.useEffect(() => {
if (editing) setEditText(rawText);
}, [editing, rawText]);
const handleCancelEdit = () => {
setEditing(false);
setEditText('');
onCancelEdit?.();
};
const handleSaveEdit = () => {
const trimmed = editText.trim();
if (trimmed && trimmed !== rawText && onEdit) {
onEdit(message.id, trimmed);
if (trimmed && trimmed !== rawText && onSaveEdit) {
onSaveEdit(message.id, trimmed);
}
setEditing(false);
setEditText('');
onCancelEdit?.();
};
const truncatedContent = typeof content === 'string'
@@ -472,26 +471,8 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, onEdit, isStreamin
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
my: 0.75,
'&:hover .edit-btn': { opacity: 1 },
}}
>
{isUser && onEdit && !editing && (
<IconButton
className="edit-btn"
size="small"
onClick={handleStartEdit}
sx={{
opacity: 0,
transition: 'opacity 0.15s',
color: c.text.tertiary,
alignSelf: 'center',
mr: 0.5,
p: 0.5,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
<Box
sx={{
maxWidth: '85%',
@@ -641,7 +622,14 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, onEdit, isStreamin
'& a': { color: c.accent.primary },
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{rawText}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
</Box>
)}
@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
@@ -15,10 +16,14 @@ import FolderIcon from '@mui/icons-material/Folder';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import SearchIcon from '@mui/icons-material/Search';
import SendIcon from '@mui/icons-material/Send';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import BrowserAgentInlineFeed from './BrowserAgentInlineFeed';
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 14 }) => {
if (service === 'gmail') {
@@ -68,7 +73,13 @@ export interface ToolPair {
result: AgentMessage | null;
}
const pulsingKeyframes = `
let toolCallKeyframesInjected = false;
function ensureToolCallKeyframes() {
if (toolCallKeyframesInjected) return;
toolCallKeyframesInjected = true;
const style = document.createElement('style');
style.setAttribute('data-tool-call-keyframes', '');
style.textContent = `
@keyframes tool-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
@@ -77,14 +88,13 @@ const pulsingKeyframes = `
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); }
50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); }
}
`;
const streamingCursorKeyframes = `
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
document.head.appendChild(style);
}
const ElapsedTimer: React.FC<{ startTime: string }> = ({ startTime }) => {
const c = useClaudeTokens();
@@ -466,6 +476,7 @@ interface ToolCallBubbleProps {
isPending?: boolean;
isStreaming?: boolean;
mcpCompact?: boolean;
sessionId?: string;
}
interface TermColors {
@@ -929,7 +940,7 @@ const GmailCard: React.FC<{ data: Record<string, any>; action: string; hideSubje
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ a: ({ children, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer">{children}</a> }}
components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}
>
{email.bodyPreview || email.snippet}
</ReactMarkdown>
@@ -1169,18 +1180,92 @@ const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> =
return <GenericMcpCard data={data} />;
};
function isBrowserAgentTool(name: string): boolean {
if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true;
const mcp = parseMcpToolName(name);
return mcp.isMcp && mcp.serverSlug === 'openswarm-browser-agent';
}
function isInvokeAgentTool(name: string): boolean {
if (name === 'InvokeAgent') return true;
const mcp = parseMcpToolName(name);
return mcp.isMcp && mcp.serverSlug === 'openswarm-invoke-agent';
}
function isCreateAgentTool(name: string): boolean {
return name === 'Agent';
}
function parseInvokedSessionId(rawText: string): string | null {
const match = rawText.match(/\(forked session:\s*([a-f0-9]+)\)/);
return match ? match[1] : null;
}
interface InvokeAgentParsed {
agentName: string;
sessionId: string | null;
cost: string | null;
response: string;
}
function parseCreateAgentResult(rawText: string): string {
if (!rawText) return '';
try {
const parsed = JSON.parse(rawText);
if (typeof parsed === 'string') return parsed;
if (typeof parsed === 'object' && parsed !== null) {
if (parsed.text) return parsed.text;
if (parsed.content) return typeof parsed.content === 'string' ? parsed.content : JSON.stringify(parsed.content);
if (parsed.result) return typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
}
} catch {}
return rawText;
}
function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null {
const headerMatch = rawText.match(
/\*\*Invoked Agent Result\*\*(?:\s*—\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/,
);
if (!headerMatch) return null;
const agentName = headerMatch[1]?.trim() || 'Agent';
const sessionId = headerMatch[2];
const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/);
const cost = costMatch ? costMatch[1] : null;
const bodyStart = rawText.indexOf('\n\n');
let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : '';
if (response.startsWith('*Cost:')) {
const afterCost = response.indexOf('\n');
response = afterCost >= 0 ? response.slice(afterCost + 1).trim() : '';
}
return { agentName, sessionId, cost, response };
}
const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
({ call, result = null, isPending = false, isStreaming = false, mcpCompact = false }) => {
({ call, result = null, isPending = false, isStreaming = false, mcpCompact = false, sessionId }) => {
ensureToolCallKeyframes();
const c = useClaudeTokens();
const tc = useTermColors();
const dispatch = useAppDispatch();
const cards = useAppSelector((s) => s.dashboardLayout.cards);
const [expanded, setExpanded] = useState(false);
const bubbleRef = useRef<HTMLDivElement>(null);
const { toolName, input, isDenied } = getToolData(call);
const mcpInfo = useMemo(() => parseMcpToolName(toolName), [toolName]);
const inputSummary = getInputSummary(toolName, input);
const formattedInput = useMemo(() => formatInputDisplay(toolName, input), [toolName, input]);
const showTimer = isPending && !isDenied && !isStreaming;
const showBody = expanded || isStreaming;
const isBrowserAgent = isBrowserAgentTool(toolName);
const isInvokeAgent = isInvokeAgentTool(toolName);
const isCreateAgent = isCreateAgentTool(toolName);
const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming;
const showBody = expanded || isStreaming || browserAgentAutoExpand;
const resultContent = result?.content;
const hasStructuredResult =
@@ -1206,6 +1291,95 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
(parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) ||
(parsedResult?.type === 'text' && parsedResult.isError);
const invokedSessionId = useMemo(
() => (isInvokeAgent && result ? parseInvokedSessionId(resultRawText) : null),
[isInvokeAgent, result, resultRawText],
);
const invokeAgentParsed = useMemo(
() => (isInvokeAgent && result ? parseInvokeAgentResult(resultRawText) : null),
[isInvokeAgent, result, resultRawText],
);
const createAgentResponse = useMemo(
() => (isCreateAgent && result ? parseCreateAgentResult(resultRawText) : ''),
[isCreateAgent, result, resultRawText],
);
const createAgentSessionId: string | null = useMemo(
() => (isCreateAgent && hasStructuredResult && resultContent?.sub_session_id) ? resultContent.sub_session_id : null,
[isCreateAgent, hasStructuredResult, resultContent],
);
const revealTargetSessionId = invokedSessionId || createAgentSessionId;
const sessions = useAppSelector((s) => s.agents.sessions);
const handleRevealAgent = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!revealTargetSessionId || !sessionId) return;
if (cards[revealTargetSessionId]) {
dispatch(collapseSession(revealTargetSessionId));
dispatch(removeCard(revealTargetSessionId));
setTimeout(() => {
dispatch(clearGlowingAgentCard(revealTargetSessionId));
}, 500);
return;
}
let sourceYRatio: number | undefined;
if (bubbleRef.current) {
const bubbleEl = bubbleRef.current;
const cardEl = bubbleEl.closest('[data-select-type="agent-card"]') as HTMLElement | null;
if (cardEl) {
const cardRect = cardEl.getBoundingClientRect();
const bubbleRect = bubbleEl.getBoundingClientRect();
const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2;
const ratio = (bubbleCenterY - cardRect.top) / cardRect.height;
sourceYRatio = Math.max(0, Math.min(1, ratio));
}
}
const doPlace = () => {
const parentCard = cards[sessionId];
const targetX = parentCard
? parentCard.x + parentCard.width + GRID_GAP * 12
: 40;
let targetY = parentCard ? parentCard.y : 100;
if (parentCard) {
const columnCards = Object.values(cards).filter(
(c) => Math.abs(c.x - targetX) < 50 && c.session_id !== revealTargetSessionId,
);
if (columnCards.length > 0) {
const lowestBottom = Math.max(
...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)),
);
targetY = lowestBottom + GRID_GAP;
}
}
dispatch(placeCard({
sessionId: revealTargetSessionId,
x: targetX,
y: targetY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
}));
dispatch(expandSession(revealTargetSessionId));
const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent';
dispatch(setGlowingAgentCard({ sessionId: revealTargetSessionId, sourceId: sessionId, sourceYRatio, label }));
};
if (!sessions[revealTargetSessionId]) {
dispatch(fetchSession(revealTargetSessionId)).then(doPlace);
} else {
doPlace();
}
},
[revealTargetSessionId, sessionId, cards, sessions, dispatch],
);
const toggle = useCallback(() => {
if (!isStreaming) setExpanded((v) => !v);
}, [isStreaming]);
@@ -1233,10 +1407,415 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }),
};
if (isInvokeAgent) {
const agentName = invokeAgentParsed?.agentName || input?.session_id || 'Agent';
const responsePreview = invokeAgentParsed?.response || '';
const costLabel = invokeAgentParsed?.cost ? `$${invokeAgentParsed.cost}` : null;
const hasResponse = !!invokeAgentParsed;
return (
<Box ref={bubbleRef} {...selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box
sx={{
'--glow-rgb': accentRgb,
bgcolor: c.bg.elevated,
border: `1px solid ${
isPending ? c.accent.primary : isDenied ? c.status.error + '60' : c.border.subtle
}`,
borderRadius: 2,
overflow: 'hidden',
animation: isPending ? 'border-glow 2s ease-in-out infinite' : 'none',
transition: 'border-color 0.3s, box-shadow 0.3s',
} as any}
>
{/* Header */}
<Box
onClick={toggle}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
cursor: hasResponse ? 'pointer' : 'default',
'&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
}}
>
<CallSplitIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.8rem',
fontWeight: 600,
flexShrink: 0,
}}
>
InvokeAgent
</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
bgcolor: `${c.accent.primary}14`,
borderRadius: 1,
px: 0.75,
py: 0.15,
maxWidth: 180,
overflow: 'hidden',
}}
>
<Typography
noWrap
sx={{
fontSize: '0.72rem',
fontWeight: 500,
color: c.text.secondary,
fontFamily: c.font.sans,
}}
>
{agentName}
</Typography>
</Box>
{!hasResponse && !showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && responsePreview && !expanded && (
<Typography
noWrap
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.73rem',
color: c.text.tertiary,
fontFamily: c.font.sans,
}}
>
{responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''}
</Typography>
)}
{expanded && <Box sx={{ flex: 1 }} />}
{isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<BlockIcon sx={{ fontSize: 13, color: c.status.error }} />
<Typography sx={{ color: c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>denied</Typography>
</Box>
)}
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{formatElapsed(resultElapsedMs)}
</Typography>
)}
{costLabel && (
<Typography sx={{ fontSize: '0.63rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{costLabel}
</Typography>
)}
</Box>
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{invokedSessionId && (
<Tooltip title="Reveal on dashboard" arrow>
<IconButton
size="small"
onClick={handleRevealAgent}
sx={{
color: c.accent.primary,
p: 0.25,
flexShrink: 0,
'&:hover': { bgcolor: `${c.accent.primary}18` },
}}
>
<CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} />
</IconButton>
</Tooltip>
)}
{hasResponse && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
</Box>
{/* Expanded body — markdown rendered, not terminal */}
<Collapse in={expanded && hasResponse}>
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
px: 1.5,
py: 1.25,
maxHeight: 400,
overflowY: 'auto',
overflowX: 'hidden',
color: c.text.secondary,
fontFamily: c.font.sans,
fontSize: '0.78rem',
lineHeight: 1.65,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4': {
color: c.text.primary, fontFamily: c.font.sans,
mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '0.88rem' }, '& h2': { fontSize: '0.84rem' },
'& h3': { fontSize: '0.8rem' }, '& h4': { fontSize: '0.78rem' },
'& strong': { color: c.text.primary, fontWeight: 600 },
'& a': { color: c.accent.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } },
'& ul, & ol': { pl: 2, mb: 0.75, mt: 0 },
'& li': { mb: 0.2 },
'& blockquote': {
m: 0, mb: 0.75, pl: 1, ml: 0,
borderLeft: `2px solid ${c.border.subtle}`,
color: c.text.tertiary, fontStyle: 'italic',
},
'& code': {
bgcolor: c.bg.secondary, px: 0.4, py: 0.15,
borderRadius: 0.5, fontSize: '0.72rem', fontFamily: c.font.mono,
},
'& pre': {
bgcolor: c.bg.secondary, borderRadius: 1, p: 1,
overflow: 'auto', fontSize: '0.72rem', fontFamily: c.font.mono,
m: 0, mb: 0.75,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${c.border.subtle}`, my: 0.75 },
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props}>{children}</a>
),
}}
>
{responsePreview}
</ReactMarkdown>
</Box>
</Collapse>
</Box>
</Box>
);
}
if (isCreateAgent) {
const taskPrompt = input?.prompt || input?.task || input?.message || '';
const taskLabel = taskPrompt
? taskPrompt.length > 40 ? taskPrompt.slice(0, 40) + '…' : taskPrompt
: 'Sub-agent';
const hasResponse = !!createAgentResponse;
return (
<Box ref={bubbleRef} {...selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box
sx={{
'--glow-rgb': accentRgb,
bgcolor: c.bg.elevated,
border: `1px solid ${
isPending ? c.accent.primary : isDenied ? c.status.error + '60' : c.border.subtle
}`,
borderRadius: 2,
overflow: 'hidden',
animation: isPending ? 'border-glow 2s ease-in-out infinite' : 'none',
transition: 'border-color 0.3s, box-shadow 0.3s',
} as any}
>
<Box
onClick={toggle}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.75,
cursor: hasResponse ? 'pointer' : 'default',
'&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
}}
>
<CallSplitIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.8rem',
fontWeight: 600,
flexShrink: 0,
}}
>
CreateAgent
</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
bgcolor: `${c.accent.primary}14`,
borderRadius: 1,
px: 0.75,
py: 0.15,
maxWidth: 180,
overflow: 'hidden',
}}
>
<Typography
noWrap
sx={{
fontSize: '0.72rem',
fontWeight: 500,
color: c.text.secondary,
fontFamily: c.font.sans,
}}
>
{taskLabel}
</Typography>
</Box>
{!hasResponse && !showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && createAgentResponse && !expanded && (
<Typography
noWrap
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.73rem',
color: c.text.tertiary,
fontFamily: c.font.sans,
}}
>
{createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''}
</Typography>
)}
{expanded && <Box sx={{ flex: 1 }} />}
{isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<BlockIcon sx={{ fontSize: 13, color: c.status.error }} />
<Typography sx={{ color: c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>denied</Typography>
</Box>
)}
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
) : (
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{formatElapsed(resultElapsedMs)}
</Typography>
)}
</Box>
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{createAgentSessionId && (
<Tooltip title="Reveal on dashboard" arrow>
<IconButton
size="small"
onClick={handleRevealAgent}
sx={{
color: c.accent.primary,
p: 0.25,
flexShrink: 0,
'&:hover': { bgcolor: `${c.accent.primary}18` },
}}
>
<CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} />
</IconButton>
</Tooltip>
)}
{hasResponse && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
</Box>
<Collapse in={expanded && hasResponse}>
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
px: 1.5,
py: 1.25,
maxHeight: 400,
overflowY: 'auto',
overflowX: 'hidden',
color: c.text.secondary,
fontFamily: c.font.sans,
fontSize: '0.78rem',
lineHeight: 1.65,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4': {
color: c.text.primary, fontFamily: c.font.sans,
mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '0.88rem' }, '& h2': { fontSize: '0.84rem' },
'& h3': { fontSize: '0.8rem' }, '& h4': { fontSize: '0.78rem' },
'& strong': { color: c.text.primary, fontWeight: 600 },
'& a': { color: c.accent.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } },
'& ul, & ol': { pl: 2, mb: 0.75, mt: 0 },
'& li': { mb: 0.2 },
'& blockquote': {
m: 0, mb: 0.75, pl: 1, ml: 0,
borderLeft: `2px solid ${c.border.subtle}`,
color: c.text.tertiary, fontStyle: 'italic',
},
'& code': {
bgcolor: c.bg.secondary, px: 0.4, py: 0.15,
borderRadius: 0.5, fontSize: '0.72rem', fontFamily: c.font.mono,
},
'& pre': {
bgcolor: c.bg.secondary, borderRadius: 1, p: 1,
overflow: 'auto', fontSize: '0.72rem', fontFamily: c.font.mono,
m: 0, mb: 0.75,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${c.border.subtle}`, my: 0.75 },
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props}>{children}</a>
),
}}
>
{createAgentResponse}
</ReactMarkdown>
</Box>
</Collapse>
</Box>
</Box>
);
}
if (mcpCompact && mcpInfo.isMcp) {
return (
<Box {...selectAttrs} sx={{ my: 0 }}>
<style>{pulsingKeyframes}</style>
<Box
onClick={toggle}
sx={{
@@ -1320,6 +1899,12 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
'&::-webkit-scrollbar-thumb': { background: tc.SCROLLBAR_THUMB, borderRadius: 3 },
}}
>
{isBrowserAgent && sessionId && (
<BrowserAgentInlineFeed
parentSessionId={sessionId}
browserId={input?.browser_id}
/>
)}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} compact />
) : parsedResult ? (
@@ -1330,7 +1915,7 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
{parsedResult.type === 'text' ? parsedResult.content : ''}
</pre>
) : null}
{!parsedResult && isPending && !isStreaming && (
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
<Box sx={{ px: 1.5, py: 1 }}>
<Box sx={{ width: 8, height: 2, bgcolor: tc.PROMPT_COLOR, animation: 'tool-pulse 1s ease-in-out infinite', borderRadius: 1 }} />
</Box>
@@ -1343,8 +1928,6 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
return (
<Box {...selectAttrs} sx={{ maxWidth: mcpCompact ? '100%' : '85%', my: mcpCompact ? 0 : 0.5 }}>
<style>{pulsingKeyframes}</style>
{isStreaming && <style>{streamingCursorKeyframes}</style>}
<Box
sx={{
'--glow-rgb': accentRgb,
@@ -1525,6 +2108,14 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
)}
</pre>
{/* Browser agent inline feed */}
{isBrowserAgent && sessionId && (
<BrowserAgentInlineFeed
parentSessionId={sessionId}
browserId={input?.browser_id}
/>
)}
{/* Output */}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} />
@@ -1566,8 +2157,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
</pre>
) : null}
{/* Pending indicator when waiting for result */}
{!parsedResult && isPending && !isStreaming && (
{/* Pending indicator when waiting for result (skip for browser agent — feed replaces it) */}
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}>
<Box
sx={{
@@ -68,9 +68,10 @@ interface Props {
group: ToolGroup;
isSessionRunning?: boolean;
meta?: ToolGroupMeta;
sessionId?: string;
}
const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning = false, meta }) => {
const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning = false, meta, sessionId }) => {
const c = useClaudeTokens();
const isMcp = !!group.mcpServer;
const [expanded, setExpanded] = useState(isMcp);
@@ -186,6 +187,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
result={pair.result}
isPending={pair.result === null && isSessionRunning}
mcpCompact
sessionId={sessionId}
/>
))}
</Box>
+236 -75
View File
@@ -22,12 +22,16 @@ import {
import {
setCardPosition,
setCardSize,
fadeGlowingAgentCard,
clearGlowingAgentCard,
removeCard,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/ApprovalBar';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { parseMcpToolName, getMcpShortAction } from '@/app/pages/AgentChat/ToolCallBubble';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
// ---------------------------------------------------------------------------
// Helper components & functions (unchanged)
@@ -168,7 +172,8 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
spawnFrom?: { x: number; y: number };
spawnFrom?: { x: number; y: number; type?: 'branch' };
exitTarget?: { x: number; y: number };
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
@@ -176,6 +181,10 @@ interface Props {
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBranch?: (sourceSessionId: string, newSessionId: string) => void;
onMeasuredHeight?: (sessionId: string, height: number) => void;
snapColumn?: { x: number; width: number };
autoFocusInput?: boolean;
}
const MIN_W = 480;
@@ -183,13 +192,54 @@ const MIN_H = 120;
const EXPANDED_OVERLAY_H = 620;
const SPAWN_SPRING = { type: 'spring' as const, stiffness: 400, damping: 28, mass: 0.6 };
const BRANCH_SPRING = { type: 'spring' as const, stiffness: 300, damping: 26, mass: 0.8 };
const EXIT_SPRING = { type: 'spring' as const, stiffness: 350, damping: 30, mass: 0.7 };
const GLOW_FADE_MS = 2500;
const SNAP_THRESHOLD = 60;
const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom,
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const cardBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = cardBoxRef.current;
if (!el || !onMeasuredHeight) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
onMeasuredHeight(session.id, entry.contentRect.height);
}
});
ro.observe(el);
return () => ro.disconnect();
}, [session.id, onMeasuredHeight]);
// ---- Glow state (for branched cards) ----
const glowEntry = useAppSelector((s) => s.dashboardLayout.glowingAgentCards[session.id]);
const isGlowingRedux = !!glowEntry;
const glowFading = glowEntry?.fading ?? false;
const glowFadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissGlow = useCallback(() => {
if (!isGlowingRedux || glowFading) return;
dispatch(fadeGlowingAgentCard(session.id));
glowFadeTimer.current = setTimeout(() => {
dispatch(clearGlowingAgentCard(session.id));
}, GLOW_FADE_MS + 300);
}, [isGlowingRedux, glowFading, dispatch, session.id]);
useEffect(() => () => {
if (glowFadeTimer.current) clearTimeout(glowFadeTimer.current);
}, []);
const accentColor = c.accent.primary;
const accentHover = c.accent.hover;
const STATUS_COLORS: Record<string, { color: string; bg: string }> = {
running: { color: c.status.success, bg: c.status.successBg },
@@ -212,6 +262,7 @@ const AgentCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
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 };
@@ -241,11 +292,15 @@ const AgentCard: React.FC<Props> = ({
const dx = (e.clientX - dragState.current.startX) / zoom;
const dy = (e.clientY - dragState.current.startY) / zoom;
if (didDrag.current) {
dispatch(setCardPosition({
sessionId: session.id,
x: dragState.current.origX + dx,
y: dragState.current.origY + dy,
}));
let finalX = dragState.current.origX + dx;
const finalY = dragState.current.origY + dy;
if (snapColumn && Math.abs(finalX - snapColumn.x) < SNAP_THRESHOLD) {
finalX = snapColumn.x;
dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight }));
}
dispatch(setCardPosition({ sessionId: session.id, x: finalX, y: finalY }));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
@@ -255,7 +310,7 @@ const AgentCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [zoom, dispatch, session.id, onDragEnd]);
}, [zoom, dispatch, session.id, onDragEnd, snapColumn, cardHeight]);
// ---- Unified edge / corner resize ----
const resizeRef = useRef<{
@@ -272,6 +327,7 @@ const AgentCard: React.FC<Props> = ({
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const effectiveW = Math.max(cardWidth, MIN_W);
@@ -337,14 +393,17 @@ const AgentCard: React.FC<Props> = ({
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
dispatch(closeSession({ sessionId: session.id }));
dispatch(collapseSession(session.id));
dispatch(removeCard(session.id));
if (glowEntry) {
setTimeout(() => {
dispatch(clearGlowingAgentCard(session.id));
}, 500);
} else {
dispatch(closeSession({ sessionId: session.id }));
}
};
const handleCollapse = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
dispatch(collapseSession(session.id));
};
useEffect(() => {
if (session.status === 'running' || session.status === 'waiting_approval') {
@@ -376,33 +435,53 @@ const AgentCard: React.FC<Props> = ({
const activeW = localResize?.w ?? cardWidth;
const activeH = localResize?.h ?? cardHeight;
const isBranchSpawn = spawnFrom?.type === 'branch';
const spawnInitial = spawnFrom
? isBranchSpawn
? { opacity: 0.5, scale: 0.92, left: spawnFrom.x, top: spawnFrom.y }
: { opacity: 0, scale: 0.3, left: spawnFrom.x, top: spawnFrom.y }
: false;
const spawnTransition = noTransition || !spawnFrom
? { duration: 0 }
: isBranchSpawn
? { left: BRANCH_SPRING, top: BRANCH_SPRING, scale: BRANCH_SPRING, opacity: { duration: 0.25 } }
: { left: SPAWN_SPRING, top: SPAWN_SPRING, scale: SPAWN_SPRING, opacity: { duration: 0.12 } };
const exitAnimation = exitTarget
? {
opacity: 0,
scale: 0.3,
left: exitTarget.x,
top: exitTarget.y,
transition: { left: EXIT_SPRING, top: EXIT_SPRING, scale: EXIT_SPRING, opacity: { duration: 0.2 } },
}
: { opacity: 0, scale: 0.85, transition: { duration: 0.2 } };
return (
<motion.div
initial={spawnFrom
? { opacity: 0, scale: 0.3, left: spawnFrom.x, top: spawnFrom.y }
: { opacity: 0, scale: 0.92, left: activeX, top: activeY }
}
layout={false}
initial={spawnInitial}
animate={{ opacity: 1, scale: 1, left: activeX, top: activeY }}
transition={noTransition
? { duration: 0 }
: spawnFrom
? { left: SPAWN_SPRING, top: SPAWN_SPRING, scale: SPAWN_SPRING, opacity: { duration: 0.12 } }
: { duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }
}
exit={exitAnimation}
transition={spawnTransition}
style={{
position: 'absolute',
zIndex: isDragging || isResizing ? 999 : expanded ? 100 : 'auto',
}}
>
<Box
ref={cardBoxRef}
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
if (!isSelected && !e.shiftKey) {
dispatch(toggleExpandSession(session.id));
}
onCardSelect?.(session.id, 'agent', e.shiftKey);
}}
onDoubleClick={() => dispatch(toggleExpandSession(session.id))}
sx={{
position: 'relative',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
@@ -410,26 +489,34 @@ const AgentCard: React.FC<Props> = ({
bgcolor: c.bg.surface,
border: isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected
? '2px solid #3b82f6'
: hasPending && !expanded
? `1px solid ${c.status.warning}`
: expanded
? `1px solid ${c.border.strong}`
: `1px solid ${c.border.subtle}`,
: (isGlowingRedux && !glowFading)
? `2px solid ${accentColor}`
: isSelected
? '2px solid #3b82f6'
: hasPending && !expanded
? `1px solid ${c.status.warning}`
: expanded
? `1px solid ${c.border.strong}`
: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2,
cursor: expanded ? 'default' : 'pointer',
transition: noTransition ? 'none' : c.transition,
transition: noTransition
? 'none'
: glowFading
? `border ${GLOW_FADE_MS}ms ease-out, box-shadow ${GLOW_FADE_MS}ms ease-out`
: c.transition,
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
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: expanded
? c.shadow.md
: c.shadow.sm,
: (isGlowingRedux && !glowFading)
? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`
: isDragging
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: expanded
? c.shadow.md
: c.shadow.sm,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
@@ -454,7 +541,18 @@ const AgentCard: React.FC<Props> = ({
},
zIndex: 50,
}),
...(!isHighlighted && !expanded && !isDragging && !isSelected && {
...(!isHighlighted && isGlowingRedux && !glowFading && {
animation: 'agent-card-glow-pulse 2s ease-in-out infinite',
'@keyframes agent-card-glow-pulse': {
'0%, 100%': {
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`,
},
'50%': {
boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25`,
},
},
}),
...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && {
'&:hover': {
boxShadow: c.shadow.md,
borderColor: hasPending ? c.status.warning : c.border.strong,
@@ -462,6 +560,82 @@ const AgentCard: React.FC<Props> = ({
}),
}}
>
{/* Glow overlays for branched cards */}
{isGlowingRedux && (
<Box
className="agent-card-glow-overlays"
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 'inherit',
zIndex: 20,
opacity: glowFading ? 0 : 1,
transition: `opacity ${GLOW_FADE_MS}ms ease-out`,
}}
>
{/* Rotating conic gradient border */}
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
overflow: 'hidden',
padding: '3px',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
WebkitMaskComposite: 'xor',
'&::before': {
content: '""',
position: 'absolute',
inset: '-50%',
background: `conic-gradient(from 0deg, transparent 0%, ${accentColor} 25%, transparent 50%, ${accentColor} 75%, transparent 100%)`,
animation: 'agent-card-rotate-glow 3s linear infinite',
},
'@keyframes agent-card-rotate-glow': {
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
{/* Top edge shimmer */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: `linear-gradient(90deg, transparent, ${accentColor}, ${accentHover}, ${accentColor}, transparent)`,
backgroundSize: '200% 100%',
animation: 'agent-card-border-shimmer 2s linear infinite',
'@keyframes agent-card-border-shimmer': {
'0%': { backgroundPosition: '200% 0' },
'100%': { backgroundPosition: '-200% 0' },
},
}}
/>
{/* Inner shadow overlay */}
<Box
sx={{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
boxShadow: `inset 0 0 40px ${accentColor}30, inset 0 0 80px ${accentColor}12`,
animation: 'agent-card-inner-pulse 2s ease-in-out infinite',
'@keyframes agent-card-inner-pulse': {
'0%, 100%': {
boxShadow: `inset 0 0 40px ${accentColor}30, inset 0 0 80px ${accentColor}12`,
},
'50%': {
boxShadow: `inset 0 0 50px ${accentColor}40, inset 0 0 100px ${accentColor}18`,
},
},
}}
/>
</Box>
)}
{/* Resize handles: 4 edges + 4 corners */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
@@ -481,9 +655,10 @@ const AgentCard: React.FC<Props> = ({
/>
))}
{/* Selection overlay blocks content interaction while selected, enabling drag from anywhere */}
{/* Selection overlay blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
<Box
ref={scrollOverlayRef}
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
@@ -491,7 +666,6 @@ const AgentCard: React.FC<Props> = ({
if (justDraggedRef.current) return;
onCardSelect?.(session.id, 'agent', e.shiftKey);
}}
onDoubleClick={() => dispatch(toggleExpandSession(session.id))}
sx={{
position: 'absolute',
inset: 0,
@@ -571,37 +745,20 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{expanded ? (
<Tooltip title="Collapse">
<IconButton
size="small"
onClick={handleCollapse}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
) : (
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
sx={{
color: c.text.ghost,
p: 0.5,
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
@@ -649,6 +806,10 @@ const AgentCard: React.FC<Props> = ({
sessionId={session.id}
onClose={() => dispatch(collapseSession(session.id))}
embedded
autoFocus={autoFocusInput}
isGlowing={isGlowingRedux && !glowFading}
onDismissGlow={dismissGlow}
onBranch={onBranch ? (newId: string) => onBranch(session.id, newId) : undefined}
/>
</Box>
)}
@@ -58,8 +58,10 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const [expanded, setExpanded] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
const [hidden, setHidden] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRunning = session.status === 'running';
const isDone = session.status === 'completed' || session.status === 'error' || session.status === 'stopped';
@@ -71,6 +73,13 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
return () => { if (fadeTimer.current) clearTimeout(fadeTimer.current); };
}, [isDone]);
useEffect(() => {
if (fadeOut) {
hideTimer.current = setTimeout(() => setHidden(true), 400);
}
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
}, [fadeOut]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -111,7 +120,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const panelW = expanded ? expandedW : collapsedW;
const panelH = expanded ? expandedH : collapsedH;
if (fadeOut) return null;
if (hidden) return null;
return (
<Box
@@ -133,7 +142,7 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
flexDirection: 'column',
overflow: 'hidden',
transition: 'width 0.25s ease, height 0.25s ease, opacity 0.4s ease',
opacity: isDone && !fadeOut ? 0.7 : 1,
opacity: fadeOut ? 0 : isDone ? 0.7 : 1,
animation: 'overlay-enter 0.3s ease-out',
'@keyframes overlay-enter': {
'0%': { opacity: 0, transform: 'translateY(8px) scale(0.95)' },
@@ -40,6 +40,7 @@ import { useBrowserActivity } from '@/shared/useBrowserActivity';
import { getActionLabel } from '@/shared/browserCommandHandler';
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
import BrowserAgentOverlay from './BrowserAgentOverlay';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -67,6 +68,14 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
const isElectron = navigator.userAgent.includes('Electron');
const chromeUserAgent = navigator.userAgent
.replace(/\s*Electron\/\S+/, '')
.replace(/\s*OpenSwarm\/\S+/, '');
const webviewPreloadPath: string | undefined = isElectron
? (window as any).openswarm?.getWebviewPreloadPath?.()
: undefined;
type WebviewElement = BrowserWebview;
interface TabLocalState {
@@ -84,6 +93,7 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
@@ -95,11 +105,12 @@ interface Props {
const BrowserCard: React.FC<Props> = ({
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1,
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
const elementSelectionCtx = useElementSelection();
const isElementSelectMode = elementSelectionCtx?.selectMode ?? false;
@@ -192,10 +203,6 @@ const BrowserCard: React.FC<Props> = ({
onTitleUpdate();
};
const onNewWindow = (e: any) => {
if (e.url) dispatch(addBrowserTab({ browserId, url: e.url, makeActive: true }));
};
const onFaviconUpdate = (e: any) => {
const favicons = e.favicons || (e.detail && e.detail.favicons);
if (favicons?.[0]) {
@@ -208,7 +215,6 @@ const BrowserCard: React.FC<Props> = ({
wv.addEventListener('page-title-updated', onTitleUpdate);
wv.addEventListener('did-start-loading', onLoadStart);
wv.addEventListener('did-stop-loading', onLoadStop);
wv.addEventListener('new-window', onNewWindow);
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
cleanups.push(() => {
@@ -218,7 +224,6 @@ const BrowserCard: React.FC<Props> = ({
wv.removeEventListener('page-title-updated', onTitleUpdate);
wv.removeEventListener('did-start-loading', onLoadStart);
wv.removeEventListener('did-stop-loading', onLoadStop);
wv.removeEventListener('new-window', onNewWindow);
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
});
}
@@ -371,6 +376,7 @@ const BrowserCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
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 };
@@ -426,6 +432,7 @@ const BrowserCard: React.FC<Props> = ({
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
@@ -491,20 +498,13 @@ const BrowserCard: React.FC<Props> = ({
const accentColor = c.accent.primary;
const accentHover = c.accent.hover;
const accentRgb = accentColor.replace('#', '').match(/.{2}/g)?.map(h => parseInt(h, 16)).join(',') || '189,100,57';
// ---- Glow state ----
const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards);
const isGlowingFromRedux = !!glowingBrowserCards[browserId];
const [hasBeenTouched, setHasBeenTouched] = useState(false);
useEffect(() => {
if (isGlowingFromRedux && agentActive) setHasBeenTouched(true);
}, [isGlowingFromRedux, agentActive]);
useEffect(() => {
if (!isGlowingFromRedux) setHasBeenTouched(false);
}, [isGlowingFromRedux]);
const showGlow = isGlowingFromRedux && hasBeenTouched;
const showGlow = isGlowingFromRedux;
const agentBorder = isHighlighted
? `2px solid ${c.accent.primary}`
@@ -576,8 +576,8 @@ const BrowserCard: React.FC<Props> = ({
},
}),
...(!isHighlighted && (agentActive || showGlow) && {
animation: 'agent-glow-pulse 2s ease-in-out infinite',
'@keyframes agent-glow-pulse': {
animation: `agent-glow-${browserId} 2s ease-in-out infinite`,
[`@keyframes agent-glow-${browserId}`]: {
'0%, 100%': {
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15${innerGlow}`,
},
@@ -588,9 +588,10 @@ const BrowserCard: React.FC<Props> = ({
}),
}}
>
{/* Selection overlay blocks content interaction while selected, enabling drag from anywhere */}
{/* Selection overlay blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
<Box
ref={scrollOverlayRef}
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
@@ -977,6 +978,9 @@ const BrowserCard: React.FC<Props> = ({
{isElementSelectMode && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 10 }} />
)}
{cmdHeld && !isSelected && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 12 }} />
)}
{isElectron ? (
tabs.map((tab) => (
<webview
@@ -988,6 +992,9 @@ const BrowserCard: React.FC<Props> = ({
data-tab-id={tab.id}
src="about:blank"
allowpopups="true"
useragent={chromeUserAgent}
{...(webviewPreloadPath ? { preload: webviewPreloadPath } : {})}
webpreferences="plugins=yes, autoplayPolicy=no-user-gesture-required"
style={{
position: 'absolute',
top: 0,
@@ -1035,6 +1042,7 @@ const BrowserCard: React.FC<Props> = ({
{/* Camera flash — screenshot */}
{(agentAction === 'screenshot' || lastAction === 'screenshot') && (
<Box
key={`flash-${activity.actionSeq}`}
sx={{
position: 'absolute',
inset: 0,
@@ -1062,10 +1070,10 @@ const BrowserCard: React.FC<Props> = ({
pointerEvents: 'none',
background: `linear-gradient(180deg, transparent, ${accentColor}90, transparent)`,
boxShadow: `0 0 12px ${accentColor}60`,
animation: 'scan-sweep 1.5s ease-in-out infinite',
animation: 'scan-sweep 1.5s ease-in-out infinite alternate',
'@keyframes scan-sweep': {
'0%': { top: '0%' },
'100%': { top: '100%' },
'100%': { top: 'calc(100% - 3px)' },
},
}}
/>
@@ -1074,10 +1082,11 @@ const BrowserCard: React.FC<Props> = ({
{/* Click ripple */}
{(agentAction === 'click' || lastAction === 'click') && (
<Box
key={`ripple-${activity.actionSeq}`}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
top: `${(activity.coords?.yPercent ?? 0.5) * 100}%`,
left: `${(activity.coords?.xPercent ?? 0.5) * 100}%`,
width: 40,
height: 40,
borderRadius: '50%',
@@ -1133,7 +1142,7 @@ const BrowserCard: React.FC<Props> = ({
</Box>
)}
{/* Orange inner shadow overlay for selection / streaming glow */}
{/* Accent inner shadow overlay for selection / streaming glow */}
{showGlow && !agentActive && (
<Box
sx={{
@@ -1142,14 +1151,14 @@ const BrowserCard: React.FC<Props> = ({
zIndex: 14,
pointerEvents: 'none',
borderRadius: 'inherit',
boxShadow: 'inset 0 0 40px rgba(255,140,0,0.35), inset 0 0 80px rgba(255,100,0,0.15)',
animation: 'orange-glow-pulse 2s ease-in-out infinite',
'@keyframes orange-glow-pulse': {
boxShadow: `inset 0 0 40px rgba(${accentRgb},0.35), inset 0 0 80px rgba(${accentRgb},0.15)`,
animation: `accent-glow-${browserId} 2s ease-in-out infinite`,
[`@keyframes accent-glow-${browserId}`]: {
'0%, 100%': {
boxShadow: 'inset 0 0 40px rgba(255,140,0,0.35), inset 0 0 80px rgba(255,100,0,0.15)',
boxShadow: `inset 0 0 40px rgba(${accentRgb},0.35), inset 0 0 80px rgba(${accentRgb},0.15)`,
},
'50%': {
boxShadow: 'inset 0 0 50px rgba(255,140,0,0.45), inset 0 0 100px rgba(255,100,0,0.22)',
boxShadow: `inset 0 0 50px rgba(${accentRgb},0.45), inset 0 0 100px rgba(${accentRgb},0.22)`,
},
},
}}
+705 -18
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useCallback, useRef, useState } from 'react';
import React, { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
@@ -9,10 +10,14 @@ import {
fetchSessions,
fetchHistory,
collapseSession,
closeSession,
duplicateSession,
expandSession,
launchAndSendFirstMessage,
generateTitle,
resumeSession,
setExpandedSessionIds,
toggleExpandSession,
} from '@/shared/state/agentsSlice';
import type { AgentConfig } from '@/shared/state/agentsSlice';
import {
@@ -25,12 +30,23 @@ import {
moveCards,
resetLayout,
setGlowingBrowserCards,
removeViewCard,
removeBrowserCard,
pasteBrowserCard,
placeCard,
removeCard,
setGlowingAgentCard,
clearGlowingAgentCard,
DEFAULT_CARD_W,
DEFAULT_CARD_H,
EXPANDED_CARD_MIN_H,
GRID_GAP,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { generateDashboardName, updateDashboardThumbnail } from '@/shared/state/dashboardsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import AgentCard from './AgentCard';
import DashboardViewCard from './DashboardViewCard';
import BrowserCard from './BrowserCard';
@@ -45,6 +61,7 @@ import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext';
import { useDomElementSelector } from '@/app/components/useDomElementSelector';
import SelectionOverlay from '@/app/components/SelectionOverlay';
import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard';
const SELECT_ATTR = 'data-select-type';
@@ -81,7 +98,10 @@ const DashboardInner: React.FC = () => {
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard);
const autoRevealSubAgents = useAppSelector((state) => state.settings.data.auto_reveal_sub_agents);
const outputs = useAppSelector((state) => state.outputs.items);
const glowingAgentCards = useAppSelector((state) => state.dashboardLayout.glowingAgentCards);
const sessionList = Object.values(sessions);
const canvas = useCanvasControls(zoomSensitivity);
@@ -96,6 +116,8 @@ const DashboardInner: React.FC = () => {
const [toolbarOpen, setToolbarOpen] = useState(false);
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
const [pendingSelectSessionId, setPendingSelectSessionId] = useState<string | null>(null);
const handleHighlightCard = useCallback((cardId: string) => {
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
@@ -106,7 +128,35 @@ const DashboardInner: React.FC = () => {
}, 2000);
}, []);
const spawnOriginsRef = useRef<Record<string, { x: number; y: number }>>({});
useEffect(() => {
if (autoFocusSessionId) {
const timer = setTimeout(() => setAutoFocusSessionId(null), 1500);
return () => clearTimeout(timer);
}
}, [autoFocusSessionId]);
useEffect(() => {
if (!pendingSelectSessionId) return;
if (!cards[pendingSelectSessionId]) return;
setPendingSelectSessionId(null);
selection.selectCard(pendingSelectSessionId, 'agent', false);
}, [pendingSelectSessionId, cards, selection]);
const spawnOriginsRef = useRef<Record<string, { x: number; y: number; type?: 'branch' }>>({});
const measuredHeightsRef = useRef<Record<string, number>>({});
const [measuredHeightsTick, setMeasuredHeightsTick] = useState(0);
const handleMeasuredHeight = useCallback((sessionId: string, height: number) => {
if (measuredHeightsRef.current[sessionId] !== height) {
measuredHeightsRef.current[sessionId] = height;
setMeasuredHeightsTick((t) => t + 1);
}
}, []);
const revealSpawnedRef = useRef(new Set<string>());
useEffect(() => {
revealSpawnedRef.current.forEach((id) => {
if (!cards[id]) revealSpawnedRef.current.delete(id);
});
}, [cards]);
const hasFittedRef = useRef(false);
const restoredExpandedRef = useRef(false);
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
@@ -114,16 +164,16 @@ const DashboardInner: React.FC = () => {
// ---- Multi-drag coordination ----
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);
const isMultiDragRef = useRef(false);
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
activeDragCardRef.current = id;
if (selection.isSelected(id)) {
activeDragCardRef.current = id;
isMultiDragRef.current = true;
} else {
selection.deselectAll();
activeDragCardRef.current = null;
isMultiDragRef.current = false;
}
}, [selection]);
@@ -132,6 +182,9 @@ const DashboardInner: React.FC = () => {
if (isMultiDragRef.current) {
setMultiDragDelta({ dx, dy });
}
if (activeDragCardRef.current) {
setLiveDragInfo({ cardId: activeDragCardRef.current, dx, dy });
}
}, []);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
@@ -145,6 +198,7 @@ const DashboardInner: React.FC = () => {
activeDragCardRef.current = null;
isMultiDragRef.current = false;
setMultiDragDelta(null);
setLiveDragInfo(null);
}, [selection, dispatch]);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
@@ -157,11 +211,17 @@ const DashboardInner: React.FC = () => {
canvas.handlers.onMouseDown(e);
return;
}
if (e.button === 2) {
e.preventDefault();
canvas.handlers.onMouseDown(e);
return;
}
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
if (isElementSelectMode) {
// Cmd/Ctrl held → allow panning even in element select mode
if (e.metaKey || e.ctrlKey) {
canvas.handlers.onMouseDown(e);
}
@@ -169,10 +229,10 @@ const DashboardInner: React.FC = () => {
}
if (e.metaKey || e.ctrlKey || canvas.spaceHeld) {
selection.handleCanvasMouseDown(e.nativeEvent);
} else {
selection.deselectAll();
canvas.handlers.onMouseDown(e);
} else {
selection.handleCanvasMouseDown(e.nativeEvent);
}
}, [canvas.handlers, canvas.spaceHeld, selection, isElementSelectMode]);
@@ -200,6 +260,20 @@ const DashboardInner: React.FC = () => {
return () => { cleanupBrowserHandler(); dashboardWs.disconnect(); };
}, [dispatch, dashboardId]);
const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl);
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
useEffect(() => {
if (!dashboardId) return;
(window as any).__openswarm_last_dashboard_id = dashboardId;
}, [dashboardId]);
useEffect(() => {
if (!pendingBrowserUrl || !layoutInitialized) return;
dispatch(addBrowserCard({ url: pendingBrowserUrl, expandedSessionIds }));
dispatch(clearPendingBrowserUrl());
}, [pendingBrowserUrl, layoutInitialized, dispatch, expandedSessionIds]);
// Capture a thumbnail screenshot of the dashboard.
// Uses Electron's native capturePage for pixel-perfect results.
// Captures current viewport as-is (no DOM mutation) to avoid visual flashes.
@@ -247,10 +321,25 @@ const DashboardInner: React.FC = () => {
useEffect(() => {
if (!layoutInitialized || hasFittedRef.current) return;
if (pendingFocusAgentId) return;
hasFittedRef.current = true;
const timer = setTimeout(() => canvas.actions.fitToView(), 150);
return () => clearTimeout(timer);
}, [layoutInitialized, canvas.actions]);
}, [layoutInitialized, canvas.actions, pendingFocusAgentId]);
useEffect(() => {
if (!pendingFocusAgentId || !layoutInitialized) return;
const agentId = pendingFocusAgentId;
dispatch(clearPendingFocusAgentId());
hasFittedRef.current = true;
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[agentId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
handleHighlightCard(agentId);
}
}, 350);
}, [pendingFocusAgentId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]);
useEffect(() => {
if (!layoutInitialized || restoredExpandedRef.current) return;
@@ -263,7 +352,7 @@ const DashboardInner: React.FC = () => {
useEffect(() => {
if (!layoutInitialized) return;
const dashboardSessionIds = Object.values(sessions)
.filter((s) => s.dashboard_id === dashboardId && s.mode !== 'browser-agent')
.filter((s) => s.dashboard_id === dashboardId && s.mode !== 'browser-agent' && s.mode !== 'invoked-agent' && s.mode !== 'sub-agent')
.map((s) => s.id);
const liveIds = dashboardSessionIds.sort().join(',');
if (liveIds === prevSessionIdsRef.current) return;
@@ -271,6 +360,100 @@ const DashboardInner: React.FC = () => {
dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds }));
}, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]);
// ---- Auto-reveal / collapse / unreveal sub-agent cards ----
const autoRevealedRef = useRef(new Set<string>());
const prevSubStatusRef = useRef<Record<string, string>>({});
const prevParentStatusRef = useRef<Record<string, string>>({});
useEffect(() => {
if (!layoutInitialized || !autoRevealSubAgents) return;
const subSessions = Object.values(sessions).filter(
(s) => (s.mode === 'sub-agent' || s.mode === 'invoked-agent') && s.parent_session_id,
);
// 1) Auto-reveal newly spawned sub-agents (skip already-terminal ones on load)
for (const sub of subSessions) {
if (autoRevealedRef.current.has(sub.id)) continue;
if (cards[sub.id]) {
autoRevealedRef.current.add(sub.id);
continue;
}
const parentCard = cards[sub.parent_session_id!];
if (!parentCard) continue;
const isTerminal = sub.status === 'completed' || sub.status === 'error' || sub.status === 'stopped';
const parentSession = sessions[sub.parent_session_id!];
const parentTerminal = parentSession &&
(parentSession.status === 'completed' || parentSession.status === 'error' || parentSession.status === 'stopped');
if (isTerminal && parentTerminal) {
autoRevealedRef.current.add(sub.id);
continue;
}
autoRevealedRef.current.add(sub.id);
const targetX = parentCard.x + parentCard.width + GRID_GAP * 12;
let targetY = parentCard.y;
const columnCards = Object.values(cards).filter(
(c) => Math.abs(c.x - targetX) < 50 && c.session_id !== sub.id,
);
if (columnCards.length > 0) {
const lowestBottom = Math.max(
...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)),
);
targetY = lowestBottom + GRID_GAP;
}
dispatch(placeCard({ sessionId: sub.id, x: targetX, y: targetY, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H }));
dispatch(expandSession(sub.id));
const label = sub.mode === 'sub-agent' ? 'Create Agent' : 'Invoke Agent';
dispatch(setGlowingAgentCard({ sessionId: sub.id, sourceId: sub.parent_session_id!, label }));
if (sub.status === 'completed' || sub.status === 'error' || sub.status === 'stopped') {
const subId = sub.id;
setTimeout(() => dispatch(collapseSession(subId)), 2000);
}
}
// 2) Auto-collapse sub-agents when they complete
const TERMINAL = new Set(['completed', 'error', 'stopped']);
for (const sub of subSessions) {
const prev = prevSubStatusRef.current[sub.id];
if (prev !== sub.status && TERMINAL.has(sub.status) && cards[sub.id]) {
dispatch(collapseSession(sub.id));
}
}
const newSubStatuses: Record<string, string> = {};
for (const sub of subSessions) { newSubStatuses[sub.id] = sub.status; }
prevSubStatusRef.current = newSubStatuses;
// 3) Unreveal all sub-agent cards when parent finishes output
const parentIds = new Set(subSessions.map((s) => s.parent_session_id!));
for (const pid of parentIds) {
const parent = sessions[pid];
if (!parent) continue;
const prev = prevParentStatusRef.current[pid];
if (prev !== parent.status && TERMINAL.has(parent.status)) {
const children = subSessions.filter((s) => s.parent_session_id === pid);
for (const child of children) {
if (!cards[child.id]) continue;
dispatch(collapseSession(child.id));
dispatch(removeCard(child.id));
setTimeout(() => {
dispatch(clearGlowingAgentCard(child.id));
}, 500);
}
}
}
const newParentStatuses: Record<string, string> = {};
for (const pid of parentIds) {
const parent = sessions[pid];
if (parent) newParentStatuses[pid] = parent.status;
}
prevParentStatusRef.current = newParentStatuses;
}, [sessions, cards, layoutInitialized, autoRevealSubAgents, dispatch]);
const skipInitialSave = useRef(true);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = useRef<Parameters<typeof saveLayout>[0] | null>(null);
@@ -326,6 +509,187 @@ const DashboardInner: React.FC = () => {
return () => window.removeEventListener('keydown', handleShortcut);
}, [newAgentShortcut]);
useEffect(() => {
const handleEnter = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
if (selection.selectedIds.size !== 1) return;
const [id, type] = selection.selectedIds.entries().next().value!;
if (type !== 'agent') return;
e.preventDefault();
dispatch(toggleExpandSession(id));
};
window.addEventListener('keydown', handleEnter);
return () => window.removeEventListener('keydown', handleEnter);
}, [selection.selectedIds, dispatch]);
useEffect(() => {
const handleDelete = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
if (selection.selectedIds.size === 0) return;
e.preventDefault();
for (const [id, type] of selection.selectedIds) {
if (type === 'agent') {
dispatch(closeSession({ sessionId: id }));
} else if (type === 'view') {
dispatch(removeViewCard(id));
} else if (type === 'browser') {
dispatch(removeBrowserCard(id));
}
}
selection.deselectAll();
};
window.addEventListener('keydown', handleDelete);
return () => window.removeEventListener('keydown', handleDelete);
}, [selection, dispatch]);
useEffect(() => {
const handleCopy = (e: KeyboardEvent) => {
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;
if (selection.selectedIds.size === 0) return;
e.preventDefault();
const copied: ClipboardCard[] = [];
const names: string[] = [];
for (const [id, type] of selection.selectedIds) {
if (type === 'agent') {
const session = sessions[id];
const card = cards[id];
if (!session || !card) continue;
copied.push({
type, id, name: session.name || id,
meta: { name: session.name, status: session.status, model: session.model, mode: session.mode },
x: card.x, y: card.y, width: card.width, height: card.height,
expanded: expandedSessionIds.includes(id),
});
names.push(session.name || id);
} else if (type === 'view') {
const output = outputs[id];
const vc = viewCards[id];
if (!output || !vc) continue;
copied.push({
type, id, name: output.name,
meta: { name: output.name, description: output.description },
x: vc.x, y: vc.y, width: vc.width, height: vc.height,
});
names.push(output.name);
} else if (type === 'browser') {
const bc = browserCards[id];
if (!bc) continue;
const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId);
const title = activeTab?.title || 'Browser';
copied.push({
type, id, name: title,
meta: { name: title, url: activeTab?.url || bc.url, tabs: bc.tabs },
x: bc.x, y: bc.y, width: bc.width, height: bc.height,
});
names.push(title);
}
}
setClipboardCards(copied);
navigator.clipboard.writeText(names.join(', ')).catch(() => {});
};
window.addEventListener('keydown', handleCopy);
return () => window.removeEventListener('keydown', handleCopy);
}, [selection.selectedIds, sessions, cards, viewCards, browserCards, outputs, expandedSessionIds]);
useEffect(() => {
const PASTE_OFFSET = 40;
const handlePaste = async (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'v') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
const copied = getClipboardCards();
if (copied.length === 0) return;
e.preventDefault();
selection.deselectAll();
const newSelection = new Map<string, CardType>();
for (const card of copied) {
const px = card.x + PASTE_OFFSET;
const py = card.y - PASTE_OFFSET;
if (card.type === 'agent') {
const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId }));
if (duplicateSession.fulfilled.match(action)) {
const newId = action.payload.id;
dispatch(placeCard({ sessionId: newId, x: px, y: py, width: card.width, height: card.height }));
if (card.expanded) {
dispatch(expandSession(newId));
}
newSelection.set(newId, 'agent');
}
} else if (card.type === 'view') {
dispatch(addViewCard({ outputId: card.id, expandedSessionIds, x: px, y: py, width: card.width, height: card.height }));
newSelection.set(card.id, 'view');
} else if (card.type === 'browser') {
const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
dispatch(pasteBrowserCard({
id: browserId, tabs: card.meta.tabs || [], url: card.meta.url || '',
x: px, y: py, width: card.width, height: card.height,
}));
newSelection.set(browserId, 'browser');
}
}
if (newSelection.size > 0) {
for (const [id, type] of newSelection) {
selection.selectCard(id, type, true);
}
}
};
window.addEventListener('keydown', handlePaste);
return () => window.removeEventListener('keydown', handlePaste);
}, [dispatch, dashboardId, expandedSessionIds, selection]);
const handleBranchFromCard = useCallback(
(sourceSessionId: string, newSessionId: string) => {
const sourceCard = cards[sourceSessionId];
if (!sourceCard) return;
const targetX = sourceCard.x + sourceCard.width + GRID_GAP * 12;
let targetY = sourceCard.y;
const columnCards = Object.values(cards).filter(
(c) => Math.abs(c.x - targetX) < 50 && c.session_id !== newSessionId,
);
if (columnCards.length > 0) {
const lowestBottom = Math.max(
...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)),
);
targetY = lowestBottom + GRID_GAP;
}
spawnOriginsRef.current[newSessionId] = {
x: sourceCard.x,
y: sourceCard.y,
type: 'branch' as const,
};
dispatch(placeCard({
sessionId: newSessionId,
x: targetX,
y: targetY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
}));
if (expandedSessionIds.includes(sourceSessionId)) {
dispatch(expandSession(newSessionId));
}
dispatch(setGlowingAgentCard({ sessionId: newSessionId, sourceId: sourceSessionId, label: 'Branch' }));
},
[cards, dispatch, expandedSessionIds],
);
const handleNewAgent = useCallback(() => {
setToolbarOpen(true);
}, []);
@@ -376,7 +740,7 @@ const DashboardInner: React.FC = () => {
contextPaths: contextPaths?.map((cp) => ({ path: cp.path, type: cp.type })),
forcedTools,
attachedSkills,
expand: false,
expand: expandNewChats,
}),
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
@@ -388,6 +752,22 @@ const DashboardInner: React.FC = () => {
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
delete spawnOriginsRef.current[draftId];
if (expandNewChats) {
setAutoFocusSessionId(realId);
} else {
setPendingSelectSessionId(realId);
}
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[realId];
if (card) {
const isExp = store.getState().agents.expandedSessionIds.includes(realId);
const height = isExp ? Math.max(EXPANDED_CARD_MIN_H, card.height) : card.height;
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 1.0, true);
handleHighlightCard(realId);
}
}, 200);
if (dashboardId) {
const currentSessions = store.getState().agents.sessions;
const agentCount = Object.values(currentSessions).filter(
@@ -408,24 +788,49 @@ const DashboardInner: React.FC = () => {
}
});
},
[canvas.viewportRef, dispatch, dashboardId],
[canvas.viewportRef, canvas.actions, dispatch, dashboardId, expandNewChats, handleHighlightCard],
);
const handleAddView = useCallback((outputId: string) => {
dispatch(addViewCard({ outputId, expandedSessionIds }));
}, [dispatch, expandedSessionIds]);
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[outputId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
handleHighlightCard(outputId);
}
}, 200);
}, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleAddBrowser = useCallback(() => {
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards));
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds }));
}, [dispatch, browserHomepage, expandedSessionIds]);
setTimeout(() => {
const allBrowserCards = store.getState().dashboardLayout.browserCards;
const newId = Object.keys(allBrowserCards).find((id) => !prevIds.has(id));
if (newId) {
const card = allBrowserCards[newId];
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
handleHighlightCard(newId);
}
}, 200);
}, [dispatch, browserHomepage, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleHistoryResume = useCallback((sessionId: string) => {
dispatch(resumeSession({ sessionId })).then((action) => {
if (resumeSession.fulfilled.match(action)) {
dispatch(collapseSession(sessionId));
dispatch(expandSession(sessionId));
setAutoFocusSessionId(sessionId);
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[sessionId];
if (card) {
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.0, true);
handleHighlightCard(sessionId);
}
}, 200);
}
});
}, [dispatch]);
}, [dispatch, canvas.actions, handleHighlightCard, setAutoFocusSessionId]);
const handleTidy = useCallback(() => {
const currentExpanded = store.getState().agents.expandedSessionIds;
@@ -444,6 +849,112 @@ const DashboardInner: React.FC = () => {
canvas.actions.fitToCards(allRects);
}, [dispatch, canvas.actions]);
useEffect(() => {
const DRIFT_THRESHOLD = 60;
// Group tethered sub-agent cards by source, only including those still in the spawn column
const sourceToSiblings = new Map<string, string[]>();
for (const [id, glow] of Object.entries(glowingAgentCards)) {
const card = cards[id];
if (!card) continue;
const sourceCard = cards[glow.sourceId];
if (!sourceCard) continue;
const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12;
if (Math.abs(card.x - expectedX) > DRIFT_THRESHOLD) continue;
const list = sourceToSiblings.get(glow.sourceId) ?? [];
list.push(id);
sourceToSiblings.set(glow.sourceId, list);
}
for (const siblings of sourceToSiblings.values()) {
if (siblings.length < 2) continue;
siblings.sort((a, b) => cards[a].y - cards[b].y);
let cursor = cards[siblings[0]].y;
for (const id of siblings) {
const card = cards[id];
const dy = cursor - card.y;
if (Math.abs(dy) > 1) {
dispatch(moveCards({ items: [{ id, type: 'agent' as const }], dx: 0, dy }));
}
const isExpanded = expandedSessionIds.includes(id);
const h = isExpanded
? Math.max(EXPANDED_CARD_MIN_H, card.height)
: (measuredHeightsRef.current[id] ?? card.height);
cursor += h + GRID_GAP * 2;
}
}
// measuredHeightsTick in deps ensures we re-run once ResizeObserver reports
// the new height after a collapse (avoids stale-height no-ops)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]);
const TETHER_FADE_MS = 2500;
const tethers = useMemo(() => {
const ELBOW_RADIUS = 16;
function elbowPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
const dy = y2 - y1;
const midX = x1 + dx / 2;
const r = (Math.abs(dy) < 1 || Math.abs(dx) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dy) / 2, Math.abs(dx) / 4);
const sy = dy >= 0 ? 1 : -1;
const sx = dx >= 0 ? 1 : -1;
return [
`M ${x1},${y1}`,
`H ${midX - sx * r}`,
`Q ${midX},${y1} ${midX},${y1 + sy * r}`,
`V ${y2 - sy * r}`,
`Q ${midX},${y2} ${midX + sx * r},${y2}`,
`H ${x2}`,
].join(' ');
}
return Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, sourceYRatio, label }]) => {
const src = cards[sourceId];
const dst = cards[copyId];
if (!src || !dst) return null;
let srcX = src.x, srcY = src.y;
let dstX = dst.x, dstY = dst.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === copyId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const srcMeasured = measuredHeightsRef.current[sourceId];
const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId)
? Math.max(EXPANDED_CARD_MIN_H, src.height)
: src.height);
const dstMeasured = measuredHeightsRef.current[copyId];
const dstH = dstMeasured ?? (expandedSessionIds.includes(copyId)
? Math.max(EXPANDED_CARD_MIN_H, dst.height)
: dst.height);
const x1 = srcX + src.width;
const y1 = srcY + srcH * 0.54;
const x2 = dstX;
const y2 = dstY + dstH * (expandedSessionIds.includes(copyId) ? 0.54 : 0.79);
const midX = x1 + (x2 - x1) / 2;
const labelX = midX + (x2 - midX) * 0.15;
const labelY = y2;
return {
key: copyId,
path: elbowPath(x1, y1, x2, y2),
labelX,
labelY,
label: label || '',
fading,
};
}).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, cards, expandedSessionIds, liveDragInfo, measuredHeightsTick]);
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -486,6 +997,7 @@ const DashboardInner: React.FC = () => {
onMouseDown={handleViewportMouseDown}
onMouseMove={handleViewportMouseMove}
onMouseUp={handleViewportMouseUp}
onContextMenu={(e) => e.preventDefault()}
sx={{
position: 'absolute',
inset: 0,
@@ -493,7 +1005,7 @@ const DashboardInner: React.FC = () => {
cursor: canvas.isPanning
? 'grabbing'
: (canvas.spaceHeld || canvas.cmdHeld)
? 'crosshair'
? 'grab'
: selection.marquee
? 'crosshair'
: 'default',
@@ -540,11 +1052,178 @@ const DashboardInner: React.FC = () => {
position: 'relative',
}}
>
{/* Tether lines between branched cards */}
{tethers.length > 0 && (
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
overflow: 'visible',
pointerEvents: 'none',
zIndex: 10,
}}
>
<defs>
<filter id="tether-glow-f" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<marker
id="tether-arrow"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="10"
markerHeight="10"
orient="auto"
>
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} opacity={0.8} />
</marker>
</defs>
<style>{`
@keyframes tether-flow { to { stroke-dashoffset: -16; } }
@keyframes tether-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
`}</style>
{tethers.map((t) => (
<g
key={t.key}
style={{
opacity: t.fading ? 0 : 1,
transition: `opacity ${TETHER_FADE_MS}ms ease-out`,
}}
>
<motion.path
initial={false}
animate={{ d: t.path }}
transition={{ type: 'spring', stiffness: 200, damping: 25, mass: 0.8 }}
fill="none"
stroke={c.accent.primary}
strokeWidth={8}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.2}
filter="url(#tether-glow-f)"
/>
<motion.path
initial={false}
animate={{ d: t.path }}
transition={{ type: 'spring', stiffness: 200, damping: 25, mass: 0.8 }}
fill="none"
stroke={c.accent.primary}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.65}
markerEnd="url(#tether-arrow)"
style={{ animation: 'tether-pulse 2s ease-in-out infinite' }}
/>
<motion.path
initial={false}
animate={{ d: t.path }}
transition={{ type: 'spring', stiffness: 200, damping: 25, mass: 0.8 }}
fill="none"
stroke={c.accent.primary}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="8 8"
opacity={0.9}
style={{ animation: 'tether-flow 0.6s linear infinite' }}
/>
{t.label && (
<motion.g
initial={false}
animate={{ x: t.labelX, y: t.labelY }}
transition={{ type: 'spring', stiffness: 200, damping: 25, mass: 0.8 }}
>
<rect
x={-4}
y={-14}
width={t.label.length * 7.5 + 8}
height={20}
rx={4}
fill={c.bg.surface}
stroke={c.accent.primary}
strokeWidth={1}
opacity={0.95}
/>
<text
x={t.label.length * 7.5 / 2}
y={1}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fontFamily="inherit"
fill={c.accent.primary}
>
{t.label}
</text>
</motion.g>
)}
</g>
))}
</svg>
)}
<AnimatePresence>
{Object.values(cards).map((card) => {
const session = sessions[card.session_id];
if (!session) return null;
const origin = spawnOriginsRef.current[session.id];
if (origin) delete spawnOriginsRef.current[session.id];
let origin = spawnOriginsRef.current[session.id];
if (origin) {
delete spawnOriginsRef.current[session.id];
} else {
const glow = glowingAgentCards[session.id];
if (glow && !revealSpawnedRef.current.has(session.id)) {
revealSpawnedRef.current.add(session.id);
const srcCard = cards[glow.sourceId];
if (srcCard) {
const srcH = measuredHeightsRef.current[glow.sourceId]
?? (expandedSessionIds.includes(glow.sourceId)
? Math.max(EXPANDED_CARD_MIN_H, srcCard.height)
: srcCard.height);
origin = {
x: srcCard.x + srcCard.width,
y: srcCard.y + srcH / 2,
type: 'branch' as const,
};
}
}
}
let exitTarget: { x: number; y: number } | undefined;
const glow = glowingAgentCards[session.id];
if (glow) {
const srcCard = cards[glow.sourceId];
if (srcCard) {
const srcH = measuredHeightsRef.current[glow.sourceId]
?? (expandedSessionIds.includes(glow.sourceId)
? Math.max(EXPANDED_CARD_MIN_H, srcCard.height)
: srcCard.height);
exitTarget = {
x: srcCard.x + srcCard.width,
y: srcCard.y + srcH / 2,
};
}
}
let snapColumn: { x: number; width: number } | undefined;
if (glow) {
const srcCard = cards[glow.sourceId];
if (srcCard) {
snapColumn = {
x: srcCard.x + srcCard.width + GRID_GAP * 12,
width: DEFAULT_CARD_W,
};
}
}
return (
<AgentCard
key={session.id}
@@ -556,6 +1235,7 @@ const DashboardInner: React.FC = () => {
cardHeight={card.height}
zoom={canvas.zoom}
spawnFrom={origin}
exitTarget={exitTarget}
isSelected={selection.isSelected(session.id)}
isHighlighted={highlightedCardId === session.id}
multiDragDelta={multiDragDelta}
@@ -563,9 +1243,14 @@ const DashboardInner: React.FC = () => {
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onBranch={handleBranchFromCard}
onMeasuredHeight={handleMeasuredHeight}
snapColumn={snapColumn}
autoFocusInput={autoFocusSessionId === session.id}
/>
);
})}
</AnimatePresence>
{Object.values(viewCards).map((vc) => {
const output = outputs[vc.output_id];
if (!output) return null;
@@ -578,6 +1263,7 @@ const DashboardInner: React.FC = () => {
cardWidth={vc.width}
cardHeight={vc.height}
zoom={canvas.zoom}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(vc.output_id)}
isHighlighted={highlightedCardId === vc.output_id}
multiDragDelta={multiDragDelta}
@@ -599,6 +1285,7 @@ const DashboardInner: React.FC = () => {
cardWidth={bc.width}
cardHeight={bc.height}
zoom={canvas.zoom}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(bc.browser_id)}
isHighlighted={highlightedCardId === bc.browser_id}
multiDragDelta={multiDragDelta}
@@ -92,7 +92,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
const handleFocus = useCallback(
(cardId: string, card: { x: number; y: number; width: number; height: number }) => {
canvasActions.fitToCards([card], 1.0);
canvasActions.fitToCards([card], 1.0, true);
onHighlightCard?.(cardId);
setExpanded(false);
},
@@ -42,6 +42,7 @@ interface Props {
dashboardId?: string;
}
const TOOLBAR_OWNER_ID = '__toolbar__';
const BTN = 40;
const WarmTooltip = styled(
@@ -90,8 +91,18 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const searchInputRef = useRef<HTMLInputElement>(null);
const historyInputRef = useRef<HTMLInputElement>(null);
const historyListRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
setMode(defaultMode || 'agent');
setModel(defaultModel || 'sonnet');
settingsApplied.current = true;
}
}, [defaultMode, defaultModel]);
const [viewPickerOpen, setViewPickerOpen] = useState(false);
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
@@ -204,13 +215,23 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const isExpanded = inputOpen || viewPickerOpen || historyOpen;
const autoSelectOnNew = useAppSelector((s) => s.settings.data.auto_select_mode_on_new_agent);
const prevInputOpenRef = useRef(inputOpen);
useEffect(() => {
if (prevInputOpenRef.current && !inputOpen && elementSelection?.selectMode) {
elementSelection.setSelectMode(false);
if (prevInputOpenRef.current && !inputOpen && elementSelection) {
elementSelection.clearOwnerElements(TOOLBAR_OWNER_ID);
if (elementSelection.selectMode && elementSelection.activeOwnerId === TOOLBAR_OWNER_ID) {
elementSelection.setSelectMode(false);
}
}
if (!prevInputOpenRef.current && inputOpen && autoSelectOnNew && elementSelection) {
elementSelection.clearOwnerElements(TOOLBAR_OWNER_ID);
elementSelection.setActiveOwnerId(TOOLBAR_OWNER_ID);
elementSelection.setExcludeSelectId(null);
elementSelection.setSelectMode(true);
}
prevInputOpenRef.current = inputOpen;
}, [inputOpen, elementSelection]);
}, [inputOpen, elementSelection, autoSelectOnNew]);
useEffect(() => {
if (!isExpanded) return;
@@ -226,21 +247,44 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
useEffect(() => {
if (!isExpanded) return;
const handleClick = (e: MouseEvent) => {
if (elementSelection?.selectMode) return;
let downPos: { x: number; y: number; target: Node } | null = null;
const DRAG_THRESHOLD = 5;
const handleDown = (e: MouseEvent) => {
const target = e.target as Node;
if (containerRef.current && !containerRef.current.contains(target)) {
const el = target instanceof Element ? target : target.parentElement;
if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root')) {
return;
}
handleDismiss();
downPos = { x: e.clientX, y: e.clientY, target };
} else {
downPos = null;
}
};
const t = setTimeout(() => document.addEventListener('mousedown', handleClick), 50);
const handleUp = (e: MouseEvent) => {
if (!downPos) return;
const dx = e.clientX - downPos.x;
const dy = e.clientY - downPos.y;
const target = downPos.target;
downPos = null;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) return;
const el = target instanceof Element ? target : (target as Node).parentElement;
if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root')) {
return;
}
if (elementSelection?.selectMode && el?.closest('[data-select-type]')) {
return;
}
handleDismiss();
};
const t = setTimeout(() => {
document.addEventListener('mousedown', handleDown, true);
document.addEventListener('mouseup', handleUp, true);
}, 50);
return () => {
clearTimeout(t);
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('mousedown', handleDown, true);
document.removeEventListener('mouseup', handleUp, true);
};
}, [isExpanded, handleDismiss, elementSelection?.selectMode]);
@@ -266,10 +310,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
e.preventDefault();
handleOpenHistory();
}
if (e.metaKey && e.key.toLowerCase() === 'n' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault();
onAddBrowser();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleOpenViewPicker, handleOpenHistory]);
}, [handleOpenViewPicker, handleOpenHistory, onAddBrowser]);
useEffect(() => {
if (!historyOpen) return;
@@ -319,6 +367,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
onModelChange={setModel}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
/>
</div>
) : historyOpen ? (
@@ -596,6 +645,39 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Browser N</Box>
</Box>
}
>
<Box
role="button"
aria-label="Browser"
tabIndex={0}
onClick={onAddBrowser}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<LanguageIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
@@ -629,39 +711,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>Browser</Box>
</Box>
}
>
<Box
role="button"
aria-label="Browser"
tabIndex={0}
onClick={onAddBrowser}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
}}
>
<LanguageIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
<WarmTooltip
key={label}
@@ -14,6 +14,7 @@ import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview';
import { getDefault } from '@/app/pages/Views/InputSchemaForm';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -45,6 +46,7 @@ interface Props {
cardWidth: number;
cardHeight: number;
zoom?: number;
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
@@ -55,11 +57,12 @@ interface Props {
}
const DashboardViewCard: React.FC<Props> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1,
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const [inputData, setInputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
@@ -77,6 +80,7 @@ const DashboardViewCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
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 };
@@ -132,6 +136,7 @@ const DashboardViewCard: React.FC<Props> = ({
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
@@ -304,9 +309,10 @@ const DashboardViewCard: React.FC<Props> = ({
}),
}}
>
{/* Selection overlay blocks content interaction while selected, enabling drag from anywhere */}
{/* Selection overlay blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
<Box
ref={scrollOverlayRef}
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
@@ -401,6 +407,9 @@ const DashboardViewCard: React.FC<Props> = ({
{/* Preview body */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{cmdHeld && !isSelected && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 12 }} />
)}
<ViewPreview
ref={previewRef}
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
@@ -38,6 +38,7 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const cmdRef = useRef(false);
const sensitivityRef = useRef(zoomSensitivity);
sensitivityRef.current = zoomSensitivity;
const animFrameRef = useRef<number | null>(null);
// Wheel zoom centered on cursor
useEffect(() => {
@@ -207,6 +208,10 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
return () => window.removeEventListener('mouseup', onUp);
}, []);
useEffect(() => {
return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); };
}, []);
const zoomIn = useCallback(() => {
setState((prev) => {
const newZoom = clamp(prev.zoom * ZOOM_IN_FACTOR, MIN_ZOOM, MAX_ZOOM);
@@ -276,7 +281,9 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
});
}, []);
const fitToCards = useCallback((cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number) => {
const fitToCards = useCallback((cardRects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean) => {
if (animFrameRef.current) { cancelAnimationFrame(animFrameRef.current); animFrameRef.current = null; }
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) {
setState({ panX: 0, panY: 0, zoom: 1 });
@@ -303,11 +310,34 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const ceiling = maxZoom ?? MAX_ZOOM;
const newZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, ceiling);
const newPanX = (vRect.width - contentWidth * newZoom) / 2 - minX * newZoom;
const newPanY = (vRect.height - contentHeight * newZoom) / 2 - minY * newZoom;
const targetZoom = clamp(Math.min(availW / contentWidth, availH / contentHeight), MIN_ZOOM, ceiling);
const targetPanX = (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
const targetPanY = (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom;
setState({ panX: newPanX, panY: newPanY, zoom: newZoom });
if (!animate) {
setState({ panX: targetPanX, panY: targetPanY, zoom: targetZoom });
return;
}
const start = { ...stateRef.current };
const startTime = performance.now();
const duration = 320;
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
setState({
panX: start.panX + (targetPanX - start.panX) * ease,
panY: start.panY + (targetPanY - start.panY) * ease,
zoom: start.zoom + (targetZoom - start.zoom) * ease,
});
if (t < 1) {
animFrameRef.current = requestAnimationFrame(step);
} else {
animFrameRef.current = null;
}
};
animFrameRef.current = requestAnimationFrame(step);
}, []);
const handlers = useMemo(() => ({
@@ -79,7 +79,10 @@ export function useDashboardSelection(
}
return next;
}
return new Map([[id, type]]);
if (prev.has(id)) {
return new Map();
}
return prev;
});
},
[],
@@ -152,7 +155,7 @@ export function useDashboardSelection(
const handleCanvasMouseDown = useCallback(
(e: MouseEvent) => {
if (e.button !== 0) return;
if (e.button !== 0 && e.button !== 2) return;
marqueeOriginRef.current = { screenX: e.clientX, screenY: e.clientY };
isDraggingMarqueeRef.current = false;
@@ -0,0 +1,80 @@
import { useRef, useEffect } from 'react';
/**
* Attaches a native wheel listener to an overlay element that forwards scroll
* events to whatever scrollable content sits beneath it, while still letting
* the overlay capture pointer events (click / drag). Pinch-zoom (ctrl/meta +
* wheel) is left alone so the canvas zoom still works.
*
* Handles two cases:
* 1. Regular DOM scrollable containers uses `scrollBy` directly.
* 2. Electron `<webview>` elements executes JS inside the webview to scroll
* the element at the cursor position.
*/
export function useOverlayScrollPassthrough(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!active || !el) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) return;
el.style.pointerEvents = 'none';
const underneath = document.elementFromPoint(e.clientX, e.clientY);
el.style.pointerEvents = '';
let dx = e.deltaX;
let dy = e.deltaY;
if (e.deltaMode === 1) {
dx *= 20;
dy *= 20;
}
let node = underneath as HTMLElement | null;
while (node) {
if (node.tagName === 'WEBVIEW') {
e.stopPropagation();
e.preventDefault();
const rect = node.getBoundingClientRect();
const relX = Math.round(e.clientX - rect.left);
const relY = Math.round(e.clientY - rect.top);
(node as any).executeJavaScript?.(
`(function(){` +
`var el=document.elementFromPoint(${relX},${relY});` +
`while(el){` +
`var s=getComputedStyle(el);` +
`if((s.overflowY==='auto'||s.overflowY==='scroll')&&el.scrollHeight>el.clientHeight){el.scrollBy(${dx},${dy});return}` +
`if((s.overflowX==='auto'||s.overflowX==='scroll')&&el.scrollWidth>el.clientWidth){el.scrollBy(${dx},${dy});return}` +
`el=el.parentElement}` +
`window.scrollBy(${dx},${dy})` +
`})()`
).catch(() => {});
return;
}
const cs = getComputedStyle(node);
const canScrollY =
node.scrollHeight > node.clientHeight &&
(cs.overflowY === 'auto' || cs.overflowY === 'scroll');
const canScrollX =
node.scrollWidth > node.clientWidth &&
(cs.overflowX === 'auto' || cs.overflowX === 'scroll');
if (canScrollY || canScrollX) {
e.stopPropagation();
e.preventDefault();
node.scrollBy(dx, dy);
return;
}
node = node.parentElement;
}
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [active]);
return ref;
}
+82 -18
View File
@@ -27,6 +27,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
import TuneIcon from '@mui/icons-material/Tune';
import LockIcon from '@mui/icons-material/Lock';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RestoreIcon from '@mui/icons-material/Restore';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
@@ -37,6 +38,7 @@ import {
createMode,
updateMode,
deleteMode,
resetMode,
Mode,
} from '@/shared/state/modesSlice';
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
@@ -105,7 +107,7 @@ const ALL_BUILTIN_TOOL_NAMES = ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep',
const Modes: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { items, loading } = useAppSelector((s) => s.modes);
const { items, builtinDefaults, loading } = useAppSelector((s) => s.modes);
const toolItems = useAppSelector((s) => s.tools.items);
const modes = useMemo(() => Object.values(items), [items]);
@@ -174,6 +176,45 @@ const Modes: React.FC = () => {
await dispatch(deleteMode(id));
};
const editingIsBuiltin = editingId ? items[editingId]?.is_builtin ?? false : false;
const hasDiverged = useMemo(() => {
if (!editingId || !editingIsBuiltin) return false;
const defaults = builtinDefaults[editingId];
if (!defaults) return false;
const current = items[editingId];
if (!current) return false;
return (
current.name !== defaults.name ||
current.description !== defaults.description ||
(current.system_prompt ?? '') !== (defaults.system_prompt ?? '') ||
JSON.stringify(current.tools) !== JSON.stringify(defaults.tools) ||
(current.default_next_mode ?? '') !== (defaults.default_next_mode ?? '') ||
current.icon !== defaults.icon ||
current.color !== defaults.color ||
(current.default_folder ?? '') !== (defaults.default_folder ?? '')
);
}, [editingId, editingIsBuiltin, items, builtinDefaults]);
const handleReset = async () => {
if (!editingId) return;
const action = await dispatch(resetMode(editingId));
if (resetMode.fulfilled.match(action)) {
const m = action.payload;
setForm({
name: m.name,
description: m.description,
system_prompt: m.system_prompt ?? '',
tools: m.tools ?? [],
toolsEnabled: m.tools !== null,
default_next_mode: m.default_next_mode ?? '',
icon: m.icon,
color: m.color,
default_folder: m.default_folder ?? '',
});
}
};
const otherModes = modes.filter((m) => m.id !== editingId);
return (
@@ -505,23 +546,46 @@ const Modes: React.FC = () => {
</FormControl>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Box>
{editingIsBuiltin && (
<Tooltip title={hasDiverged ? 'Restore this mode to its original built-in defaults' : 'Mode matches built-in defaults'}>
<span>
<Button
startIcon={<RestoreIcon sx={{ fontSize: 16 }} />}
onClick={handleReset}
disabled={!hasDiverged}
sx={{
color: hasDiverged ? c.text.muted : c.text.ghost,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': hasDiverged ? { color: c.status.error, bgcolor: `${c.status.error}10` } : {},
}}
>
Reset to Default
</Button>
</span>
</Tooltip>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
</Box>
</DialogActions>
</Dialog>
+68 -10
View File
@@ -11,6 +11,7 @@ import InputAdornment from '@mui/material/InputAdornment';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Slider from '@mui/material/Slider';
import Switch from '@mui/material/Switch';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Tab from '@mui/material/Tab';
@@ -495,7 +496,7 @@ const Settings: React.FC = () => {
</Box>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>New agent shortcut</Typography>
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
@@ -553,6 +554,51 @@ const Settings: React.FC = () => {
</Box>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-enable element selection</Typography>
<Typography sx={descSx}>Automatically enter element selection mode when creating a new agent.</Typography>
</Box>
<Switch
checked={form.auto_select_mode_on_new_agent}
onChange={(e) => setForm({ ...form, auto_select_mode_on_new_agent: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Default agent spawn state in dashboard</Typography>
<Typography sx={descSx}>When enabled, new agents spawn expanded instead of collapsed.</Typography>
</Box>
<Switch
checked={form.expand_new_chats_in_dashboard}
onChange={(e) => setForm({ ...form, expand_new_chats_in_dashboard: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Auto-reveal sub-agents on dashboard</Typography>
<Typography sx={descSx}>Automatically show sub-agent cards (from CreateAgent / InvokeAgent) tethered to their parent on the dashboard.</Typography>
</Box>
<Switch
checked={form.auto_reveal_sub_agents}
onChange={(e) => setForm({ ...form, auto_reveal_sub_agents: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
{/* ── Browser ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
@@ -639,15 +685,8 @@ const Settings: React.FC = () => {
{step.title}
{step.link && (
<Typography
component="span"
onClick={() => {
const w = window as any;
if (w.openswarm?.openExternal) {
w.openswarm.openExternal(step.link);
} else {
window.open(step.link, '_blank', 'noopener');
}
}}
component="a"
href={step.link}
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
@@ -657,6 +696,7 @@ const Settings: React.FC = () => {
alignItems: 'center',
gap: 0.3,
verticalAlign: 'middle',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
@@ -705,6 +745,24 @@ const Settings: React.FC = () => {
/>
</Box>
{/* ── Advanced ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Developer mode</Typography>
<Typography sx={descSx}>Show transport details, environment variables, raw configs, and other technical metadata throughout the app.</Typography>
</Box>
<Switch
checked={form.dev_mode}
onChange={(e) => setForm({ ...form, dev_mode: e.target.checked })}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
{/* ── About ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>About</Typography>
-2
View File
@@ -579,8 +579,6 @@ const Skills: React.FC = () => {
size="small"
component="a"
href={selectedReg.repositoryUrl}
target="_blank"
rel="noopener"
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<OpenInNewIcon sx={{ fontSize: 18 }} />
+349 -42
View File
@@ -56,6 +56,7 @@ import BlockIcon from '@mui/icons-material/Block';
import VisibilityIcon from '@mui/icons-material/Visibility';
import SecurityIcon from '@mui/icons-material/Security';
import PanToolIcon from '@mui/icons-material/PanTool';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import RefreshIcon from '@mui/icons-material/Refresh';
import {
fetchTools,
@@ -74,6 +75,8 @@ import {
import {
searchRegistry,
fetchRegistryStats,
fetchServerDetail,
clearDetail,
McpServer,
} from '@/shared/state/mcpRegistrySlice';
import {
@@ -156,7 +159,7 @@ const INTEGRATIONS: Integration[] = [
},
];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'planning', 'scheduling'];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
interface ToolForm {
name: string;
@@ -235,6 +238,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
interaction: { label: 'Interaction', color: '#a855f7', icon: <QuestionAnswerIcon sx={{ fontSize: 16 }} /> },
planning: { label: 'Planning', color: '#ec4899', icon: <MapIcon sx={{ fontSize: 16 }} /> },
scheduling: { label: 'Scheduling', color: '#14b8a6', icon: <ScheduleIcon sx={{ fontSize: 16 }} /> },
agents: { label: 'Agents', color: '#f97316', icon: <CallSplitIcon sx={{ fontSize: 16 }} /> },
};
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
@@ -343,7 +347,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
return (
<Box key={bt.name} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, '&:hover': { bgcolor: c.bg.secondary } }}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{bt.name}</Typography>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{bt.display_name || bt.name}</Typography>
{bt.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(bt.description)}</Typography>}
</Box>
<PermToggle value={toolPolicy} onChange={(v) => onPermissionChange(bt.name, v)} size={14} />
@@ -370,7 +374,8 @@ const Tools: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { items, builtinTools, builtinPermissions, loading } = useAppSelector((s) => s.tools);
const { servers: regServers, total: regTotal, loading: regLoading, stats: regStats } = useAppSelector((s) => s.mcpRegistry);
const { servers: regServers, total: regTotal, loading: regLoading, stats: regStats, detail: regDetail, detailLoading: regDetailLoading } = useAppSelector((s) => s.mcpRegistry);
const devMode = useAppSelector((s) => s.settings.data.dev_mode);
const outputItems = useAppSelector((s) => s.outputs.items);
const outputs = useMemo(() => Object.values(outputItems), [outputItems]);
const allTools = Object.values(items);
@@ -488,7 +493,8 @@ const Tools: React.FC = () => {
if (discoverTools.fulfilled.match(result)) {
setSnackbar({ open: true, message: 'Actions discovered successfully' });
} else {
setSnackbar({ open: true, message: 'Discovery failed — is the MCP server running?', severity: 'error' });
const detail = (result as any).error?.message || 'Discovery failed — is the MCP server running?';
setSnackbar({ open: true, message: detail, severity: 'error' });
}
} finally {
setDiscovering(false);
@@ -530,8 +536,11 @@ const Tools: React.FC = () => {
};
const [expandedServices, setExpandedServices] = useState<Record<string, boolean>>({});
const [expandedSchema, setExpandedSchema] = useState<string | null>(null);
const [viewsSectionOpen, setViewsSectionOpen] = useState(false);
const [browserSectionOpen, setBrowserSectionOpen] = useState(false);
const [browserCollapsed, setBrowserCollapsed] = useState<Record<string, boolean>>({ browser_delegation: true, browser_action: true });
const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true);
useEffect(() => {
@@ -556,8 +565,12 @@ const Tools: React.FC = () => {
};
// Built-in tool grouping
const coreTools = useMemo(() => builtinTools.filter((bt) => !bt.deferred), [builtinTools]);
const deferredTools = useMemo(() => builtinTools.filter((bt) => bt.deferred), [builtinTools]);
const BROWSER_CATEGORIES = new Set(['browser_delegation', 'browser_action']);
const coreTools = useMemo(() => builtinTools.filter((bt) => !bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]);
const deferredTools = useMemo(() => builtinTools.filter((bt) => bt.deferred && !BROWSER_CATEGORIES.has(bt.category)), [builtinTools]);
const browserTools = useMemo(() => builtinTools.filter((bt) => BROWSER_CATEGORIES.has(bt.category)), [builtinTools]);
const browserDelegationTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_delegation'), [browserTools]);
const browserActionTools = useMemo(() => browserTools.filter((bt) => bt.category === 'browser_action'), [browserTools]);
const groupTools = (list: BuiltinTool[]) => {
const g: Record<string, BuiltinTool[]> = {};
for (const bt of list) { if (!g[bt.category]) g[bt.category] = []; g[bt.category].push(bt); }
@@ -578,10 +591,14 @@ const Tools: React.FC = () => {
() => !outputs.every((o) => o.permission === 'deny'),
[outputs],
);
const browserSectionEnabled = useMemo(
() => browserTools.length > 0 && !browserTools.every((t) => builtinPermissions[t.name] === 'deny'),
[browserTools, builtinPermissions],
);
const handleSectionEnabledChange = async (tools: BuiltinTool[], enabled: boolean) => {
const perms: Record<string, string> = {};
for (const t of tools) perms[t.name] = enabled ? 'ask' : 'deny';
for (const t of tools) perms[t.name] = enabled ? 'always_allow' : 'deny';
await dispatch(updateBuiltinPermissions(perms));
};
@@ -894,7 +911,7 @@ const Tools: React.FC = () => {
{builtinSectionOpen ? <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' }} />}
<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Action Sets</Typography>
<Chip label={coreTools.length + deferredTools.length + outputs.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
<Chip label={coreTools.length + deferredTools.length + outputs.length + browserTools.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={builtinSectionOpen}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
@@ -992,6 +1009,156 @@ const Tools: React.FC = () => {
</Card>
)}
{/* Browser */}
{browserTools.length > 0 && (
<Card sx={{ bgcolor: c.bg.surface, border: `1px solid ${browserSectionOpen && browserSectionEnabled ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: c.accent.primary, boxShadow: '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
<Box
onClick={() => browserSectionEnabled && setBrowserSectionOpen((v) => !v)}
sx={{ display: 'flex', alignItems: 'center', gap: 2, cursor: browserSectionEnabled ? 'pointer' : 'default' }}
>
<Box sx={{
width: 36, height: 36, borderRadius: 2, display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: c.bg.secondary, color: c.text.tertiary, flexShrink: 0,
opacity: browserSectionEnabled ? 1 : 0.4, transition: 'opacity 0.2s',
}}>
<PublicIcon sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0, opacity: browserSectionEnabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>Browser</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Browser automation delegation and individual browser actions</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Switch
checked={browserSectionEnabled}
onChange={(_, checked) => handleSectionEnabledChange(browserTools, checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
{browserSectionEnabled && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 18, color: c.text.ghost, transition: 'transform 0.2s', transform: browserSectionOpen ? 'rotate(180deg)' : 'rotate(0deg)' }} />
</Box>
)}
</Box>
</CardContent>
<Collapse in={browserSectionOpen && browserSectionEnabled}>
<Box sx={{ px: 2, pb: 2, pt: 0, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{/* Delegation group */}
{browserDelegationTools.length > 0 && (() => {
const delegationPolicies = browserDelegationTools.map((t) => builtinPermissions[t.name] || 'always_allow');
const groupPolicy = delegationPolicies.every((p) => p === 'always_allow') ? 'always_allow'
: delegationPolicies.every((p) => p === 'deny') ? 'deny'
: delegationPolicies.every((p) => p === 'ask') ? 'ask' : 'ask';
const isOpen = !browserCollapsed.browser_delegation;
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', '&:hover': { borderColor: c.border.medium } }}>
<Box
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 0.75, cursor: 'pointer', bgcolor: isOpen ? c.bg.secondary : 'transparent', '&:hover': { bgcolor: c.bg.secondary } }}
onClick={() => setBrowserCollapsed((p) => ({ ...p, browser_delegation: !p.browser_delegation }))}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Delegation</Typography>
<Chip label={browserDelegationTools.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserDelegationTools.map((t) => t.name), 'always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'always_allow' ? `${c.status.success}20` : 'transparent', color: groupPolicy === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserDelegationTools.map((t) => t.name), 'ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'ask' ? `${c.status.warning}20` : 'transparent', color: groupPolicy === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserDelegationTools.map((t) => t.name), 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: groupPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
</Box>
</Box>
<Collapse in={isOpen}>
<Box sx={{ px: 1, pb: 1 }}>
{browserDelegationTools.map((bt) => {
const toolPolicy = builtinPermissions[bt.name] || 'always_allow';
return (
<Box key={bt.name} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, '&:hover': { bgcolor: c.bg.secondary } }}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{bt.display_name || bt.name}</Typography>
{bt.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{bt.description}</Typography>}
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'always_allow' ? `${c.status.success}20` : 'transparent', color: toolPolicy === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'ask' ? `${c.status.warning}20` : 'transparent', color: toolPolicy === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: toolPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
</Box>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
})()}
{/* Browser Actions group */}
{browserActionTools.length > 0 && (() => {
const actionPolicies = browserActionTools.map((t) => builtinPermissions[t.name] || 'always_allow');
const groupPolicy = actionPolicies.every((p) => p === 'always_allow') ? 'always_allow'
: actionPolicies.every((p) => p === 'deny') ? 'deny'
: actionPolicies.every((p) => p === 'ask') ? 'ask' : 'ask';
const isOpen = !browserCollapsed.browser_action;
return (
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', '&:hover': { borderColor: c.border.medium } }}>
<Box
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 1.5, py: 0.75, cursor: 'pointer', bgcolor: isOpen ? c.bg.secondary : 'transparent', '&:hover': { bgcolor: c.bg.secondary } }}
onClick={() => setBrowserCollapsed((p) => ({ ...p, browser_action: !p.browser_action }))}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Browser Actions</Typography>
<Chip label={browserActionTools.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserActionTools.map((t) => t.name), 'always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'always_allow' ? `${c.status.success}20` : 'transparent', color: groupPolicy === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserActionTools.map((t) => t.name), 'ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'ask' ? `${c.status.warning}20` : 'transparent', color: groupPolicy === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserActionTools.map((t) => t.name), 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: groupPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
</Box>
</Box>
<Collapse in={isOpen}>
<Box sx={{ px: 1, pb: 1 }}>
{browserActionTools.map((bt) => {
const toolPolicy = builtinPermissions[bt.name] || 'always_allow';
return (
<Box key={bt.name} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, '&:hover': { bgcolor: c.bg.secondary } }}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{bt.display_name || bt.name}</Typography>
{bt.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{bt.description}</Typography>}
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title="Always allow"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'always_allow')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'always_allow' ? `${c.status.success}20` : 'transparent', color: toolPolicy === 'always_allow' ? c.status.success : c.text.ghost, '&:hover': { bgcolor: `${c.status.success}15`, color: c.status.success } }}><CheckCircleIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
<Tooltip title="Ask permission"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'ask')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'ask' ? `${c.status.warning}20` : 'transparent', color: toolPolicy === 'ask' ? c.status.warning : c.text.ghost, '&:hover': { bgcolor: `${c.status.warning}15`, color: c.status.warning } }}><PanToolIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinPermissionChange(bt.name, 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: toolPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: toolPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 14 }} /></IconButton></Tooltip>
</Box>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
})()}
</Box>
</Box>
</Collapse>
</Card>
)}
</Box>
</Collapse>
</Box>
@@ -1029,7 +1196,7 @@ const Tools: React.FC = () => {
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>{ig.name}</Typography>
<Chip component="a" href={ig.website} target="_blank" rel="noopener" clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
<Chip component="a" href={ig.website} clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
</Box>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{ig.description}</Typography>
</Box>
@@ -1059,6 +1226,7 @@ const Tools: React.FC = () => {
const perms = tool.tool_permissions || {};
const services = perms._services as Record<string, { read?: string[]; write?: string[] }> | undefined;
const descriptions = (perms._tool_descriptions || {}) as Record<string, string>;
const schemas = (perms._tool_schemas || {}) as Record<string, any>;
const serviceNames = services ? Object.keys(services) : [];
const hasPerms = serviceNames.length > 0;
const totalToolCount = serviceNames.reduce((acc, s) => acc + (services![s].read?.length || 0) + (services![s].write?.length || 0), 0);
@@ -1131,15 +1299,36 @@ const Tools: React.FC = () => {
</Box>
<PermToggle value={getGroupPolicy(data.read!) === 'mixed' ? 'ask' : getGroupPolicy(data.read!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.read!, v)} size={14} />
</Box>
{data.read!.map((name) => (
<Box key={name} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, '&:hover': { bgcolor: c.bg.secondary } }}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
{data.read!.map((name) => {
const schemaKey = `${tool.id}:${name}`;
const schema = schemas[name];
const schemaProps = schema?.properties as Record<string, any> | undefined;
const schemaRequired = (schema?.required || []) as string[];
return (
<Box key={name}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, cursor: devMode && schema ? 'pointer' : undefined, '&:hover': { bgcolor: c.bg.secondary } }} onClick={() => devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
{devMode && expandedSchema === schemaKey && schemaProps && (
<Box sx={{ mx: 1.5, mb: 0.75, px: 1.5, py: 1, bgcolor: c.bg.page, borderRadius: 1, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.65rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>Input Parameters</Typography>
{Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => (
<Box key={pName} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, py: 0.2 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.72rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>{pName}</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.68rem', fontFamily: c.font.mono }}>{pDef?.type || 'any'}</Typography>
{schemaRequired.includes(pName) && <Chip label="required" size="small" sx={{ bgcolor: `${c.status.error}12`, color: c.status.error, fontSize: '0.55rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />}
{pDef?.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pDef.description}</Typography>}
</Box>
))}
</Box>
)}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
))}
);
})}
</Box>
)}
{(data.write?.length || 0) > 0 && (
@@ -1152,15 +1341,36 @@ const Tools: React.FC = () => {
</Box>
<PermToggle value={getGroupPolicy(data.write!) === 'mixed' ? 'ask' : getGroupPolicy(data.write!)} onChange={(v) => handleGroupPermissionChange(tool.id, data.write!, v)} size={14} />
</Box>
{data.write!.map((name) => (
<Box key={name} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, '&:hover': { bgcolor: c.bg.secondary } }}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
{data.write!.map((name) => {
const schemaKey = `${tool.id}:${name}`;
const schema = schemas[name];
const schemaProps = schema?.properties as Record<string, any> | undefined;
const schemaRequired = (schema?.required || []) as string[];
return (
<Box key={name}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', py: 0.4, px: 1.5, borderRadius: 1, cursor: devMode && schema ? 'pointer' : undefined, '&:hover': { bgcolor: c.bg.secondary } }} onClick={() => devMode && schema && setExpandedSchema((p) => p === schemaKey ? null : schemaKey)}>
<Box sx={{ minWidth: 0, flex: 1, mr: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>{toDisplayName(name, serviceName)}</Typography>
{descriptions[name] && <Typography sx={{ color: c.text.ghost, fontSize: '0.7rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{firstSentence(descriptions[name])}</Typography>}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
{devMode && expandedSchema === schemaKey && schemaProps && (
<Box sx={{ mx: 1.5, mb: 0.75, px: 1.5, py: 1, bgcolor: c.bg.page, borderRadius: 1, border: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.65rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>Input Parameters</Typography>
{Object.entries(schemaProps).map(([pName, pDef]: [string, any]) => (
<Box key={pName} sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, py: 0.2 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.72rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>{pName}</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.68rem', fontFamily: c.font.mono }}>{pDef?.type || 'any'}</Typography>
{schemaRequired.includes(pName) && <Chip label="required" size="small" sx={{ bgcolor: `${c.status.error}12`, color: c.status.error, fontSize: '0.55rem', height: 14, '& .MuiChip-label': { px: 0.4 } }} />}
{pDef?.description && <Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pDef.description}</Typography>}
</Box>
))}
</Box>
)}
</Box>
<PermToggle value={perms[name] || 'ask'} onChange={(v) => handlePermissionChange(tool.id, name, v)} size={14} />
</Box>
))}
);
})}
</Box>
)}
</Box>
@@ -1202,7 +1412,7 @@ const Tools: React.FC = () => {
<Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
)}
{ig && (
<Chip component="a" href={ig.website} target="_blank" rel="noopener" clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
<Chip component="a" href={ig.website} clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
)}
</Box>
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
@@ -1330,6 +1540,46 @@ const Tools: React.FC = () => {
))}
</Box>
)}
{devMode && isMcp && (
<Box sx={{ mt: 2, pt: 1.5, borderTop: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Developer Info
</Typography>
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.5 }}>
MCP Config
</Typography>
<Typography component="pre" sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono, whiteSpace: 'pre-wrap', wordBreak: 'break-all', m: 0, lineHeight: 1.5 }}>
{JSON.stringify(tool.mcp_config, null, 2)}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Auth type:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_type || 'none'}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Status:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.auth_status || 'none'}</Typography>
</Box>
{tool.connected_account_email && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Account:</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', fontFamily: c.font.mono }}>{tool.connected_account_email}</Typography>
</Box>
)}
</Box>
{tool.credentials && Object.keys(tool.credentials).length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Credentials:</Typography>
{Object.keys(tool.credentials).map((key) => (
<Chip key={key} label={`${key}: configured`} size="small" sx={{ bgcolor: `${c.status.success}12`, color: c.status.success, fontSize: '0.65rem', height: 18, fontFamily: c.font.mono, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
</Box>
)}
</Box>
</Collapse>
</Card>
@@ -1366,17 +1616,24 @@ const Tools: React.FC = () => {
<StorefrontIcon sx={{ color: c.accent.primary }} />
MCP Registry
{regStats && (
<Chip
label={
regSource === 'google'
? `${regStats.google.toLocaleString()} Google servers`
: regSource === 'community'
? `${regStats.community.toLocaleString()} Community servers`
: `${regStats.total.toLocaleString()} servers`
}
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, ml: 'auto' }}
/>
<>
<Chip
label={
regSource === 'google'
? `${regStats.google.toLocaleString()} Google servers`
: regSource === 'community'
? `${regStats.community.toLocaleString()} Community servers`
: `${regStats.total.toLocaleString()} servers`
}
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, ml: 'auto' }}
/>
{devMode && regStats.lastUpdated > 0 && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.68rem', flexShrink: 0 }}>
Synced {Math.round((Date.now() / 1000 - regStats.lastUpdated) / 60)}m ago
</Typography>
)}
</>
)}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 0, px: 3, pb: 0, overflow: 'hidden',
@@ -1463,10 +1720,18 @@ const Tools: React.FC = () => {
</Typography>
{regServers.map((srv) => {
const isExpanded = expandedServer === srv.name;
const isInstalled = allTools.some((t) => t.name === (srv.title || cleanServerName(srv.name)));
return (
<Box key={srv.name}>
<Box
onClick={() => setExpandedServer(isExpanded ? null : srv.name)}
onClick={() => {
const next = isExpanded ? null : srv.name;
setExpandedServer(next);
if (next && devMode) {
dispatch(clearDetail());
dispatch(fetchServerDetail(srv.name));
}
}}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
px: 1.5, py: 1, borderRadius: 1.5, cursor: 'pointer',
@@ -1504,6 +1769,12 @@ const Tools: React.FC = () => {
</Typography>
</Box>
)}
{devMode && !srv.remoteType && (
<Chip label="stdio" size="small" sx={{ bgcolor: '#8b5cf615', color: '#8b5cf6', fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{isInstalled && (
<Chip icon={<CheckCircleIcon sx={{ fontSize: 12 }} />} label="Installed" size="small" sx={{ bgcolor: `${c.status.success}15`, color: c.status.success, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, color: c.status.success } }} />
)}
</Box>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.78rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{srv.description}
@@ -1533,8 +1804,6 @@ const Tools: React.FC = () => {
<Chip
component="a"
href={srv.websiteUrl}
target="_blank"
rel="noopener"
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Website"
@@ -1546,8 +1815,6 @@ const Tools: React.FC = () => {
<Chip
component="a"
href={srv.repositoryUrl}
target="_blank"
rel="noopener"
clickable
icon={<OpenInNewIcon sx={{ fontSize: 12 }} />}
label="Repository"
@@ -1558,6 +1825,46 @@ const Tools: React.FC = () => {
</Box>
</Box>
{devMode && (
<Box sx={{ mb: 2 }}>
{regDetailLoading && expandedServer === srv.name ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : regDetail && regDetail.name === srv.name ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{(regDetail.keywords?.length > 0 || regDetail.license) && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
{regDetail.license && (
<Chip label={regDetail.license} size="small" sx={{ bgcolor: `${c.status.info}15`, color: c.status.info, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
{regDetail.keywords?.map((kw) => (
<Chip key={kw} label={kw} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
))}
</Box>
)}
{regDetail.environmentVariables?.length > 0 && (
<Box sx={{ bgcolor: c.bg.page, borderRadius: 1.5, border: `1px solid ${c.border.subtle}`, px: 1.5, py: 1 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.75 }}>
Required Environment Variables
</Typography>
{regDetail.environmentVariables.map((ev) => (
<Box key={ev.name} sx={{ display: 'flex', alignItems: 'baseline', gap: 1, py: 0.25 }}>
<Typography sx={{ color: c.accent.primary, fontSize: '0.75rem', fontFamily: c.font.mono, fontWeight: 600, flexShrink: 0 }}>
{ev.name}
</Typography>
{ev.description && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>
{ev.description}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
) : null}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
+3 -28
View File
@@ -9,7 +9,6 @@ import Tab from '@mui/material/Tab';
import Tooltip from '@mui/material/Tooltip';
import Switch from '@mui/material/Switch';
import CircularProgress from '@mui/material/CircularProgress';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import HtmlIcon from '@mui/icons-material/Code';
@@ -382,7 +381,7 @@ interface FileTreeItemProps {
c: ReturnType<typeof useClaudeTokens>;
}
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json']);
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
@@ -733,6 +732,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const outputFiles = { ...files };
delete outputFiles['meta.json'];
delete outputFiles['schema.json'];
delete outputFiles['SKILL.md'];
return {
name: name || 'Untitled App',
@@ -795,27 +795,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
await performSaveRef.current?.(close);
};
const handleClose = async () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
const eid = output?.id ?? createdIdRef.current;
if (!savedRef.current && (files['index.html'] ?? '').trim()) {
try {
const body = buildBody();
let savedId: string;
if (eid) {
await dispatch(updateOutput({ id: eid, ...body })).unwrap();
savedId = eid;
} else {
const created = await dispatch(createOutput(body)).unwrap();
savedId = created.id;
}
captureThumbnailAsync(savedId);
} catch {}
}
onClose();
};
const handleRunPreview = async () => {
const eid = output?.id ?? createdIdRef.current;
if (!eid) {
@@ -970,7 +949,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html`
: undefined;
const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json').sort(), [files]);
const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json' && p !== 'SKILL.md').sort(), [files]);
const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]);
const updateFile = useCallback((path: string, content: string) => {
@@ -1136,10 +1115,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
minHeight: 44,
}}
>
<IconButton onClick={handleClose} size="small" sx={{ color: c.text.muted }}>
<ArrowBackIcon fontSize="small" />
</IconButton>
<TextField
value={name}
onChange={(e) => setName(e.target.value)}
+4 -1
View File
@@ -39,6 +39,9 @@ const Views: React.FC = () => {
setEditorOpen(true);
} else if (routeId && routeId !== 'new') {
navigate('/apps', { replace: true });
} else if (!routeId) {
setEditorOpen(false);
setEditingOutput(null);
}
}, [routeId, loaded, items, navigate]);
@@ -62,7 +65,7 @@ const Views: React.FC = () => {
};
if (editorOpen) {
return <ViewEditor output={editingOutput} onClose={handleEditorClose} />;
return <ViewEditor key={editingOutput?.id ?? 'new'} output={editingOutput} onClose={handleEditorClose} />;
}
return (
+119 -17
View File
@@ -4,11 +4,12 @@ import { resolveInput } from './resolveUrl';
let initialized = false;
export type BrowserAction = 'screenshot' | 'get_text' | 'navigate' | 'click' | 'type' | 'evaluate' | 'get_elements';
export type BrowserAction = 'screenshot' | 'get_text' | 'navigate' | 'click' | 'type' | 'evaluate' | 'get_elements' | 'scroll' | 'wait';
export interface BrowserActivity {
action: BrowserAction;
detail?: string;
coords?: { xPercent: number; yPercent: number };
}
type ActivityListener = (browserId: string, activity: BrowserActivity | null) => void;
@@ -42,6 +43,8 @@ const ACTION_LABELS: Record<string, string> = {
type: 'Typing...',
evaluate: 'Evaluating...',
get_elements: 'Inspecting...',
scroll: 'Scrolling...',
wait: 'Waiting...',
};
export function getActionLabel(action: string): string {
@@ -94,6 +97,8 @@ async function handleClick(wv: BrowserWebview, params: Record<string, any>): Pro
return {
text: 'Clicked element: ' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
url: location.href,
clickX: window.innerWidth > 0 ? x / window.innerWidth : 0.5,
clickY: window.innerHeight > 0 ? y / window.innerHeight : 0.5,
};
})()`;
const result = await wv.executeJavaScript(code);
@@ -128,6 +133,84 @@ async function handleType(wv: BrowserWebview, params: Record<string, any>): Prom
return result;
}
async function handleScroll(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const direction = (params.direction as string) || 'down';
const amount = (params.amount as number) || 500;
const code = `(() => {
function findScrollable() {
const candidates = document.querySelectorAll(
'[class*="scroller"], [class*="scroll-container"], [class*="content"], '
+ 'main, [role="main"], article, .notion-scroller, .notion-frame'
);
for (const el of candidates) {
const s = window.getComputedStyle(el);
const isScrollable = (s.overflow === 'auto' || s.overflow === 'scroll'
|| s.overflowY === 'auto' || s.overflowY === 'scroll');
if (isScrollable && el.scrollHeight > el.clientHeight + 10) return el;
}
const all = document.querySelectorAll('*');
for (const el of all) {
if (el === document.body || el === document.documentElement) continue;
const s = window.getComputedStyle(el);
const isScrollable = (s.overflow === 'auto' || s.overflow === 'scroll'
|| s.overflowY === 'auto' || s.overflowY === 'scroll');
if (isScrollable && el.scrollHeight > el.clientHeight + 50
&& el.clientHeight > 200) return el;
}
return null;
}
const dy = ${JSON.stringify(direction)} === 'up' ? -${amount} : ${amount};
const container = findScrollable();
if (container) {
const before = container.scrollTop;
container.scrollBy({ top: dy, behavior: 'instant' });
const after = container.scrollTop;
return {
scrolled: Math.abs(after - before),
scrollTop: after,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
atTop: after <= 0,
atBottom: after + container.clientHeight >= container.scrollHeight - 5,
target: 'container',
};
}
const before = window.scrollY;
window.scrollBy({ top: dy, behavior: 'instant' });
const after = window.scrollY;
return {
scrolled: Math.abs(after - before),
scrollTop: after,
scrollHeight: document.documentElement.scrollHeight,
clientHeight: window.innerHeight,
atTop: after <= 0,
atBottom: after + window.innerHeight >= document.documentElement.scrollHeight - 5,
target: 'window',
};
})()`;
try {
const result = await wv.executeJavaScript(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`,
...result,
url: wv.getURL(),
};
} catch (err: any) {
return { error: `Scroll failed: ${err?.message || String(err)}` };
}
}
async function handleWait(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const ms = Math.min(Math.max((params.milliseconds as number) || 1000, 100), 10000);
await new Promise((resolve) => setTimeout(resolve, ms));
return {
text: `Waited ${ms}ms. Current URL: ${wv.getURL()}`,
url: wv.getURL(),
title: wv.getTitle(),
};
}
async function handleGetElements(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
const scope = (params.selector as string) || 'body';
const safeScope = JSON.stringify(scope);
@@ -135,51 +218,57 @@ async function handleGetElements(wv: BrowserWebview, params: Record<string, any>
const scope = document.querySelector(${safeScope}) || document.body;
const interactive = scope.querySelectorAll(
'a[href], button, input, textarea, select, [role="button"], [role="link"], '
+ '[role="textbox"], [role="searchbox"], [onclick], [tabindex]:not([tabindex="-1"])'
+ '[role="textbox"], [role="searchbox"], [role="menuitem"], [role="tab"], '
+ '[role="checkbox"], [role="switch"], [role="option"], '
+ '[onclick], [tabindex]:not([tabindex="-1"]), '
+ '[data-block-id], [contenteditable="true"]'
);
const seen = new Set();
const results = [];
for (const el of interactive) {
if (results.length >= 60) break;
if (results.length >= 80) break;
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
if (window.getComputedStyle(el).visibility === 'hidden') continue;
const style = window.getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none') continue;
if (style.opacity === '0') continue;
let selector = el.tagName.toLowerCase();
if (el.id) {
selector = '#' + el.id;
selector = '#' + CSS.escape(el.id);
} else if (el.getAttribute('data-block-id')) {
selector = '[data-block-id="' + el.getAttribute('data-block-id') + '"]';
} else if (el.getAttribute('name')) {
selector = el.tagName.toLowerCase() + '[name="' + el.getAttribute('name') + '"]';
selector = el.tagName.toLowerCase() + '[name="' + CSS.escape(el.getAttribute('name')) + '"]';
} else if (el.getAttribute('aria-label')) {
selector = el.tagName.toLowerCase() + '[aria-label="' + el.getAttribute('aria-label') + '"]';
selector = el.tagName.toLowerCase() + '[aria-label="' + CSS.escape(el.getAttribute('aria-label')) + '"]';
} else if (el.getAttribute('type') && el.tagName === 'INPUT') {
selector = 'input[type="' + el.getAttribute('type') + '"]';
if (el.getAttribute('placeholder'))
selector += '[placeholder="' + el.getAttribute('placeholder') + '"]';
selector += '[placeholder="' + CSS.escape(el.getAttribute('placeholder')) + '"]';
} else if (el.className && typeof el.className === 'string') {
const cls = el.className.trim().split(/\\s+/)[0];
if (cls && cls.length < 40)
if (cls && cls.length < 60)
selector = el.tagName.toLowerCase() + '.' + CSS.escape(cls);
}
const verify = document.querySelectorAll(selector);
if (verify.length > 1) {
if (seen.has(selector)) {
const parent = el.parentElement;
if (parent && parent.id) {
selector = '#' + parent.id + ' > ' + selector;
selector = '#' + CSS.escape(parent.id) + ' > ' + selector;
} else {
const siblings = parent ? Array.from(parent.querySelectorAll(':scope > ' + el.tagName.toLowerCase())) : [];
const siblings = parent ? Array.from(parent.children) : [];
const idx = siblings.indexOf(el);
if (idx >= 0 && parent)
selector = (parent.tagName.toLowerCase() + (parent.className ? '.' + CSS.escape(parent.className.trim().split(/\\s+/)[0]) : ''))
+ ' > ' + el.tagName.toLowerCase() + ':nth-child(' + (idx + 1) + ')';
if (idx >= 0) selector += ':nth-child(' + (idx + 1) + ')';
}
}
seen.add(selector);
results.push({
selector,
tag: el.tagName.toLowerCase(),
type: el.type || null,
text: (el.textContent || '').trim().substring(0, 80) || null,
text: (el.textContent || '').trim().substring(0, 120) || null,
placeholder: el.placeholder || null,
ariaLabel: el.getAttribute('aria-label') || null,
role: el.getAttribute('role') || null,
@@ -238,6 +327,13 @@ async function handleBrowserCommand(data: Record<string, any>) {
break;
case 'click':
result = await handleClick(wv, params);
if (result.clickX != null && result.clickY != null) {
setActivity(browser_id, {
action: 'click',
detail,
coords: { xPercent: result.clickX, yPercent: result.clickY },
});
}
break;
case 'type':
result = await handleType(wv, params);
@@ -248,6 +344,12 @@ async function handleBrowserCommand(data: Record<string, any>) {
case 'get_elements':
result = await handleGetElements(wv, params);
break;
case 'scroll':
result = await handleScroll(wv, params);
break;
case 'wait':
result = await handleWait(wv, params);
break;
default:
result = { error: `Unknown browser action: ${action}` };
}
+9
View File
@@ -51,6 +51,15 @@ export function getAllWebviews(): Map<string, BrowserWebview> {
return new Map(registry);
}
export function findBrowserByWebContentsId(wcId: number): string | undefined {
for (const [key, wv] of registry.entries()) {
if ((wv as any).getWebContentsId?.() === wcId) {
return key.split(':')[0];
}
}
return undefined;
}
export function unregisterAllForBrowser(browserId: string): void {
const prefix = `${browserId}:`;
for (const key of registry.keys()) {
+34
View File
@@ -0,0 +1,34 @@
import type { CardType } from '@/app/pages/Dashboard/useDashboardSelection';
export interface ClipboardCard {
type: CardType;
id: string;
name: string;
meta: Record<string, any>;
x: number;
y: number;
width: number;
height: number;
expanded?: boolean;
}
let clipboardCards: ClipboardCard[] = [];
let clipboardTimestamp = 0;
export function setClipboardCards(cards: ClipboardCard[]): void {
clipboardCards = cards;
clipboardTimestamp = Date.now();
}
export function getClipboardCards(): ClipboardCard[] {
return clipboardCards;
}
export function getClipboardTimestamp(): number {
return clipboardTimestamp;
}
export function clearClipboard(): void {
clipboardCards = [];
clipboardTimestamp = 0;
}
+96 -11
View File
@@ -14,6 +14,7 @@ export interface AgentMessage {
attached_skills?: Array<{ id: string; name: string }>;
forced_tools?: string[];
images?: Array<{ data: string; media_type: string }>;
hidden?: boolean;
}
export interface ApprovalRequest {
@@ -69,6 +70,7 @@ export interface AgentSession {
tool_group_meta: Record<string, ToolGroupMeta>;
dashboard_id?: string;
browser_id?: string | null;
parent_session_id?: string | null;
}
export interface AgentConfig {
@@ -109,6 +111,7 @@ interface AgentsState {
expandedSessionIds: string[];
loading: boolean;
historySearch: HistorySearchState;
trackedNotificationIds: string[];
}
const initialState: AgentsState = {
@@ -118,6 +121,7 @@ const initialState: AgentsState = {
expandedSessionIds: [],
loading: false,
historySearch: { results: [], total: 0, hasMore: false, query: '', loading: false },
trackedNotificationIds: [],
};
export const fetchSessions = createAsyncThunk(
@@ -151,15 +155,17 @@ export interface SendMessagePayload {
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
hidden?: boolean;
selectedBrowserIds?: string[];
}
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: SendMessagePayload) => {
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
});
return { sessionId, prompt };
}
@@ -212,6 +218,7 @@ export interface LaunchAndSendPayload {
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
expand?: boolean;
selectedBrowserIds?: string[];
}
export const fetchSession = createAsyncThunk(
@@ -225,7 +232,7 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: LaunchAndSendPayload) => {
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -237,7 +244,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
@@ -328,6 +335,20 @@ export const closeSession = createAsyncThunk(
}
);
export const duplicateSession = createAsyncThunk(
'agents/duplicateSession',
async ({ sessionId, dashboardId, upToMessageId }: { sessionId: string; dashboardId?: string; upToMessageId?: string }) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dashboard_id: dashboardId, up_to_message_id: upToMessageId }),
});
if (!res.ok) throw new Error('Failed to duplicate session');
const data = await res.json();
return data.session as AgentSession;
}
);
export const deleteSession = createAsyncThunk(
'agents/deleteSession',
async ({ sessionId }: { sessionId: string }) => {
@@ -380,6 +401,15 @@ export const resumeSession = createAsyncThunk(
}
);
export const fetchBrowserAgentChildren = createAsyncThunk(
'agents/fetchBrowserAgentChildren',
async (parentSessionId: string) => {
const res = await fetch(`${AGENTS_API}/sessions/${parentSessionId}/browser-agents`);
const data = await res.json();
return data.sessions as AgentSession[];
}
);
const agentsSlice = createSlice({
name: 'agents',
initialState,
@@ -497,6 +527,9 @@ const agentsSlice = createSlice({
streamingMessage: existing?.streamingMessage ?? action.payload.streamingMessage ?? null,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
};
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.id)) {
state.trackedNotificationIds.push(action.payload.id);
}
},
updateSessionStatus(
@@ -507,6 +540,9 @@ const agentsSlice = createSlice({
if (session) {
session.status = action.payload.status;
}
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) {
state.trackedNotificationIds.push(action.payload.sessionId);
}
},
addMessage(state, action: PayloadAction<{ sessionId: string; message: AgentMessage }>) {
@@ -629,7 +665,19 @@ const agentsSlice = createSlice({
closeSessionFromWs(state, action: PayloadAction<HistorySession>) {
const entry = action.payload;
state.history[entry.id] = entry;
delete state.sessions[entry.id];
const session = state.sessions[entry.id];
if (session?.mode === 'browser-agent' && session.parent_session_id) {
session.status = (entry.status as AgentSession['status']) || 'completed';
} else {
delete state.sessions[entry.id];
for (const [id, s] of Object.entries(state.sessions)) {
if (s.mode === 'browser-agent' && s.parent_session_id === entry.id) {
delete state.sessions[id];
}
}
}
if (state.activeSessionId === entry.id) {
state.activeSessionId = null;
}
@@ -651,6 +699,18 @@ const agentsSlice = createSlice({
clearHistorySearch(state) {
state.historySearch = { results: [], total: 0, hasMore: false, query: '', loading: false };
},
trackAgentNotification(state, action: PayloadAction<string>) {
if (!state.trackedNotificationIds.includes(action.payload)) {
state.trackedNotificationIds.push(action.payload);
}
},
dismissAgentNotification(state, action: PayloadAction<string>) {
state.trackedNotificationIds = state.trackedNotificationIds.filter(
(id) => id !== action.payload,
);
},
},
extraReducers: (builder) => {
builder
@@ -682,6 +742,9 @@ const agentsSlice = createSlice({
if (!state.expandedSessionIds.includes(action.payload.id)) {
state.expandedSessionIds.push(action.payload.id);
}
if (!state.trackedNotificationIds.includes(action.payload.id)) {
state.trackedNotificationIds.push(action.payload.id);
}
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
@@ -693,6 +756,9 @@ const agentsSlice = createSlice({
if (shouldExpand && !state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);
}
if (!state.trackedNotificationIds.includes(session.id)) {
state.trackedNotificationIds.push(session.id);
}
})
.addCase(generateTitle.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
@@ -721,6 +787,8 @@ const agentsSlice = createSlice({
const session = state.sessions[action.payload];
if (session) {
session.status = 'stopped';
session.streamingMessage = null;
session.pending_approvals = [];
}
})
.addCase(handleApproval.fulfilled, (state, action) => {
@@ -736,6 +804,10 @@ const agentsSlice = createSlice({
session.active_branch_id = action.payload.branchId;
}
})
.addCase(duplicateSession.fulfilled, (state, action) => {
const session = action.payload;
state.sessions[session.id] = session;
})
.addCase(closeSession.fulfilled, (state, action) => {
const sessionId = action.payload;
const session = state.sessions[sessionId];
@@ -788,6 +860,7 @@ const agentsSlice = createSlice({
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(fetchHistory.fulfilled, (state, action) => {
const history: Record<string, HistorySession> = {};
@@ -808,12 +881,22 @@ const agentsSlice = createSlice({
.addCase(fetchSession.fulfilled, (state, action) => {
const session = action.payload;
const existing = state.sessions[session.id];
if (existing) {
state.sessions[session.id] = {
...session,
streamingMessage: existing.streamingMessage ?? null,
tool_group_meta: session.tool_group_meta ?? existing.tool_group_meta ?? {},
};
state.sessions[session.id] = {
...session,
pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [],
streamingMessage: existing?.streamingMessage ?? null,
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
};
})
.addCase(fetchBrowserAgentChildren.fulfilled, (state, action) => {
for (const session of action.payload) {
if (!state.sessions[session.id]) {
state.sessions[session.id] = {
...session,
streamingMessage: null,
tool_group_meta: session.tool_group_meta ?? {},
};
}
}
})
.addCase(searchHistory.pending, (state) => {
@@ -864,6 +947,8 @@ export const {
closeSessionFromWs,
removeDraftSession,
clearHistorySearch,
trackAgentNotification,
dismissAgentNotification,
} = agentsSlice.actions;
export default agentsSlice.reducer;
+117 -29
View File
@@ -11,7 +11,7 @@ export const DEFAULT_VIEW_CARD_H = 800;
export const DEFAULT_BROWSER_CARD_W = 1280;
export const DEFAULT_BROWSER_CARD_H = 800;
export const EXPANDED_CARD_MIN_H = 620;
const GRID_GAP = 24;
export const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
@@ -53,7 +53,9 @@ export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, string>;
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
persistedExpandedSessionIds: string[];
loading: boolean;
initialized: boolean;
@@ -63,7 +65,9 @@ const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
closedCardPositions: {},
glowingBrowserCards: {},
glowingAgentCards: {},
persistedExpandedSessionIds: [],
loading: false,
initialized: false,
@@ -214,6 +218,14 @@ const dashboardLayoutSlice = createSlice({
}
},
placeCard(
state,
action: PayloadAction<{ sessionId: string; x: number; y: number; width: number; height: number }>
) {
const { sessionId, x, y, width, height } = action.payload;
state.cards[sessionId] = { session_id: sessionId, x, y, width, height };
},
removeCard(state, action: PayloadAction<string>) {
delete state.cards[action.payload];
},
@@ -227,6 +239,7 @@ const dashboardLayoutSlice = createSlice({
for (const id of Object.keys(state.cards)) {
if (!liveIds.has(id)) {
state.closedCardPositions[id] = { ...state.cards[id] };
delete state.cards[id];
}
}
@@ -235,15 +248,21 @@ const dashboardLayoutSlice = createSlice({
const newIds = sessionIds.filter((id) => !state.cards[id]);
for (const id of newIds) {
if (hasDraftCard && !id.startsWith('draft-')) continue;
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_CARD_W, DEFAULT_CARD_H);
state.cards[id] = {
session_id: id,
x: pos.x,
y: pos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
};
const savedPos = state.closedCardPositions[id];
if (savedPos) {
state.cards[id] = { ...savedPos, session_id: id };
delete state.closedCardPositions[id];
} else {
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_CARD_W, DEFAULT_CARD_H);
state.cards[id] = {
session_id: id,
x: pos.x,
y: pos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
};
}
}
},
@@ -259,9 +278,9 @@ const dashboardLayoutSlice = createSlice({
if (total === 0) return;
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedH: c.height })),
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
@@ -270,12 +289,11 @@ const dashboardLayoutSlice = createSlice({
for (const item of allItems) {
let w: number, h: number;
if (item.kind === 'agent') {
w = DEFAULT_CARD_W;
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : DEFAULT_CARD_H;
} else if (item.kind === 'view') {
w = DEFAULT_VIEW_CARD_W; h = DEFAULT_VIEW_CARD_H;
w = item.storedW;
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : item.storedH;
} else {
w = DEFAULT_BROWSER_CARD_W; h = DEFAULT_BROWSER_CARD_H;
w = item.storedW;
h = item.storedH;
}
const pos = findOpenGridCell(placedRects, w, h);
@@ -283,28 +301,39 @@ const dashboardLayoutSlice = createSlice({
if (item.kind === 'agent') {
const card = state.cards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
if (card) { card.x = pos.x; card.y = pos.y; }
} else if (item.kind === 'view') {
const card = state.viewCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
if (card) { card.x = pos.x; card.y = pos.y; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
if (card) { card.x = pos.x; card.y = pos.y; }
}
}
},
addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[] }>) {
const { outputId, expandedSessionIds } = action.payload;
addViewCard(state, action: PayloadAction<{
outputId: string; expandedSessionIds?: string[];
x?: number; y?: number; width?: number; height?: number;
}>) {
const { outputId, expandedSessionIds, x, y, width, height } = action.payload;
if (state.viewCards[outputId]) return;
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H);
let posX: number, posY: number;
if (x != null && y != null) {
posX = x;
posY = y;
} else {
const rects = collectOccupiedRects(state, expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H);
posX = pos.x;
posY = pos.y;
}
state.viewCards[outputId] = {
output_id: outputId,
x: pos.x,
y: pos.y,
width: DEFAULT_VIEW_CARD_W,
height: DEFAULT_VIEW_CARD_H,
x: posX,
y: posY,
width: width || DEFAULT_VIEW_CARD_W,
height: height || DEFAULT_VIEW_CARD_H,
};
},
@@ -385,6 +414,44 @@ const dashboardLayoutSlice = createSlice({
delete state.browserCards[action.payload];
},
pasteBrowserCard(
state,
action: PayloadAction<{
tabs: BrowserTab[]; url: string; expandedSessionIds?: string[];
id?: string; x?: number; y?: number; width?: number; height?: number;
}>
) {
const { x, y, width, height } = action.payload;
const id = action.payload.id || `browser-${Date.now().toString(36)}`;
const newTabs = action.payload.tabs.map((t) => ({
id: generateTabId(),
url: t.url,
title: '',
favicon: undefined,
}));
const activeTab = newTabs[0];
let posX: number, posY: number;
if (x != null && y != null) {
posX = x;
posY = y;
} else {
const rects = collectOccupiedRects(state, action.payload.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H);
posX = pos.x;
posY = pos.y;
}
state.browserCards[id] = {
browser_id: id,
url: activeTab?.url || action.payload.url,
tabs: newTabs.length > 0 ? newTabs : [{ id: generateTabId(), url: action.payload.url, title: '' }],
activeTabId: activeTab?.id || generateTabId(),
x: posX,
y: posY,
width: width || DEFAULT_BROWSER_CARD_W,
height: height || DEFAULT_BROWSER_CARD_H,
};
},
updateBrowserCardUrl(
state,
action: PayloadAction<{ browserId: string; url: string }>
@@ -556,11 +623,27 @@ const dashboardLayoutSlice = createSlice({
state.glowingBrowserCards = {};
},
setGlowingAgentCard(state, action: PayloadAction<{ sessionId: string; sourceId: string; sourceYRatio?: number; label?: string }>) {
const { sessionId, sourceId, sourceYRatio, label } = action.payload;
state.glowingAgentCards[sessionId] = { sourceId, fading: false, sourceYRatio, label };
},
fadeGlowingAgentCard(state, action: PayloadAction<string>) {
const entry = state.glowingAgentCards[action.payload];
if (entry) entry.fading = true;
},
clearGlowingAgentCard(state, action: PayloadAction<string>) {
delete state.glowingAgentCards[action.payload];
},
resetLayout(state) {
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
state.glowingAgentCards = {};
state.persistedExpandedSessionIds = [];
state.initialized = false;
},
@@ -596,6 +679,7 @@ const dashboardLayoutSlice = createSlice({
export const {
setCardPosition,
placeCard,
setCardSize,
removeCard,
reconcileSessions,
@@ -610,6 +694,7 @@ export const {
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
pasteBrowserCard,
updateBrowserCardUrl,
addBrowserTab,
removeBrowserTab,
@@ -622,6 +707,9 @@ export const {
setGlowingBrowserCards,
clearGlowingBrowserCards,
clearAllGlowingBrowserCards,
setGlowingAgentCard,
fadeGlowingAgentCard,
clearGlowingAgentCard,
resetLayout,
} = dashboardLayoutSlice.actions;
+15 -3
View File
@@ -18,18 +18,19 @@ export interface Mode {
interface ModesState {
items: Record<string, Mode>;
builtinDefaults: Record<string, Mode>;
loading: boolean;
loaded: boolean;
}
const initialState: ModesState = { items: {}, loading: false, loaded: false };
const initialState: ModesState = { items: {}, builtinDefaults: {}, loading: false, loaded: false };
export const fetchModes = createAsyncThunk(
'modes/fetch',
async () => {
const res = await fetch(`${MODES_API}/list`);
const data = await res.json();
return data.modes as Mode[];
return { modes: data.modes as Mode[], builtinDefaults: (data.builtin_defaults ?? {}) as Record<string, Mode> };
},
{ condition: (_, { getState }) => !(getState() as { modes: ModesState }).modes.loading },
);
@@ -60,6 +61,15 @@ export const updateMode = createAsyncThunk(
}
);
export const resetMode = createAsyncThunk(
'modes/reset',
async (id: string) => {
const res = await fetch(`${MODES_API}/${id}/reset`, { method: 'POST' });
const data = await res.json();
return data.mode as Mode;
}
);
export const deleteMode = createAsyncThunk('modes/delete', async (id: string) => {
await fetch(`${MODES_API}/${id}`, { method: 'DELETE' });
return id;
@@ -76,11 +86,13 @@ const modesSlice = createSlice({
state.loading = false;
state.loaded = true;
state.items = {};
for (const m of action.payload) state.items[m.id] = m;
for (const m of action.payload.modes) state.items[m.id] = m;
state.builtinDefaults = action.payload.builtinDefaults;
})
.addCase(fetchModes.rejected, (state) => { state.loading = false; state.loaded = true; })
.addCase(createMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(updateMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(resetMode.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(deleteMode.fulfilled, (state, action) => { delete state.items[action.payload]; });
},
});
@@ -22,6 +22,10 @@ export interface AppSettings {
new_agent_shortcut: string;
anthropic_api_key: string | null;
browser_homepage: string;
auto_select_mode_on_new_agent: boolean;
expand_new_chats_in_dashboard: boolean;
auto_reveal_sub_agents: boolean;
dev_mode: boolean;
}
export interface BrowseResult {
@@ -50,6 +54,10 @@ const initialState: SettingsState = {
new_agent_shortcut: 'Meta+l',
anthropic_api_key: null,
browser_homepage: 'https://www.google.com',
auto_select_mode_on_new_agent: false,
expand_new_chats_in_dashboard: false,
auto_reveal_sub_agents: true,
dev_mode: false,
},
loading: false,
loaded: false,
@@ -3,10 +3,16 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TempState {
temp_state: string | null;
pendingBrowserUrl: string | null;
pendingFocusAgentId: string | null;
lastDashboardId: string | null;
}
const initialState: TempState = {
temp_state: null,
pendingBrowserUrl: null,
pendingFocusAgentId: null,
lastDashboardId: null,
};
const tempStateSlice = createSlice({
@@ -19,12 +25,32 @@ const tempStateSlice = createSlice({
resetTempState(state) {
state.temp_state = null;
},
setPendingBrowserUrl(state, action: PayloadAction<string>) {
state.pendingBrowserUrl = action.payload;
},
clearPendingBrowserUrl(state) {
state.pendingBrowserUrl = null;
},
setLastDashboardId(state, action: PayloadAction<string>) {
state.lastDashboardId = action.payload;
},
setPendingFocusAgentId(state, action: PayloadAction<string>) {
state.pendingFocusAgentId = action.payload;
},
clearPendingFocusAgentId(state) {
state.pendingFocusAgentId = null;
},
},
});
export const {
setTempState,
resetTempState,
setPendingBrowserUrl,
clearPendingBrowserUrl,
setLastDashboardId,
setPendingFocusAgentId,
clearPendingFocusAgentId,
} = tempStateSlice.actions;
export default tempStateSlice.reducer;
+1
View File
@@ -20,6 +20,7 @@ export interface ToolDefinition {
export interface BuiltinTool {
name: string;
display_name?: string;
description: string;
category: string;
deferred: boolean;
+13 -5
View File
@@ -12,15 +12,19 @@ export interface BrowserActivityState {
detail: string | null;
/** The action that just completed — stays set briefly for exit animations */
lastAction: BrowserAction | null;
/** Increments on each new action — use as React key to restart CSS animations */
actionSeq: number;
/** Viewport-relative click coordinates (0-1 range) for positioning the click ripple */
coords: { xPercent: number; yPercent: number } | null;
}
const EMPTY: BrowserActivityState = { active: false, action: null, detail: null, lastAction: null };
const EMPTY: BrowserActivityState = { active: false, action: null, detail: null, lastAction: null, actionSeq: 0, coords: null };
export function useBrowserActivity(browserId: string): BrowserActivityState {
const [state, setState] = useState<BrowserActivityState>(() => {
const current = getActivity(browserId);
return current
? { active: true, action: current.action, detail: current.detail ?? null, lastAction: null }
? { active: true, action: current.action, detail: current.detail ?? null, lastAction: null, actionSeq: 0, coords: current.coords ?? null }
: EMPTY;
});
@@ -31,21 +35,25 @@ export function useBrowserActivity(browserId: string): BrowserActivityState {
if (changedId !== browserId) return;
if (activity) {
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
setState({
setState((prev) => ({
active: true,
action: activity.action,
detail: activity.detail ?? null,
lastAction: null,
});
actionSeq: prev.actionSeq + 1,
coords: activity.coords ?? prev.coords,
}));
} else {
setState((prev) => ({
active: false,
action: null,
detail: null,
lastAction: prev.action,
actionSeq: prev.actionSeq,
coords: prev.coords,
}));
lastActionTimer.current = setTimeout(() => {
setState((prev) => (prev.active ? prev : { ...prev, lastAction: null }));
setState((prev) => (prev.active ? prev : { ...prev, lastAction: null, coords: null }));
}, 600);
}
},
@@ -14,6 +14,7 @@ import {
addBranch,
setActiveBranch,
closeSessionFromWs,
trackAgentNotification,
} from '../state/agentsSlice';
import { addBrowserCardFromBackend } from '../state/dashboardLayoutSlice';
@@ -125,6 +126,9 @@ class WebSocketManager {
} else if (session_id) {
store.dispatch(updateSessionStatus({ sessionId: session_id, status: data.status }));
}
if (data.status === 'running' && session_id) {
store.dispatch(trackAgentNotification(session_id));
}
break;
case 'agent:message':
+5
View File
@@ -10,6 +10,8 @@ declare global {
partition?: string;
allowpopups?: string;
nodeintegration?: string;
webpreferences?: string;
useragent?: string;
},
HTMLElement
>;
@@ -31,7 +33,9 @@ declare global {
interface OpenSwarmAPI {
getBackendPort: () => number;
getWebviewPreloadPath: () => string;
getAppVersion: () => Promise<string>;
getUpdateStatus: () => Promise<{ status: string; info: any; error: string | null }>;
checkForUpdates: () => Promise<{ success: boolean; version?: string; error?: string }>;
downloadUpdate: () => Promise<{ success: boolean; error?: string }>;
installUpdate: () => Promise<void>;
@@ -40,6 +44,7 @@ declare global {
onDownloadProgress: (cb: (progress: OpenSwarmDownloadProgress) => void) => () => void;
onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
onUpdateError: (cb: (message: string) => void) => () => void;
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
}
interface Window {
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -127,10 +127,18 @@ if (( frontend_elapsed >= FRONTEND_MAX_WAIT )); then
exit 1
fi
# --- Sign Electron VMP for DRM (if EVS account exists) ---
if [ -f "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" ]; then
echo -e "${YELLOW}${BOLD}[vmp]${RESET} Checking VMP signature..."
bash "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" 2>&1 | while IFS= read -r line; do
printf "${YELLOW}${BOLD}%s${RESET}\n" "$line"
done
fi
# --- Start Electron in dev mode ---
MAGENTA='\033[0;35m'
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..."
(cd "$PROJECT_ROOT/electron" && ELECTRON_DEV=1 npx electron .) > >(
(cd "$PROJECT_ROOT/electron" && unset ELECTRON_RUN_AS_NODE && ELECTRON_DEV=1 npx electron .) > >(
while IFS= read -r line; do
printf "${MAGENTA}${BOLD}[electron]${RESET} %s\n" "$line"
done
+32 -4
View File
@@ -48,7 +48,7 @@ if $PUBLISH_MODE; then
fi
# Step 1: Build frontend
echo "[1/3] Building frontend..."
echo "[1/4] Building frontend..."
cd "$PROJECT_ROOT/frontend"
npm install
npm run build
@@ -61,7 +61,7 @@ echo "Frontend build complete."
echo ""
# Step 2: Build Python environment
echo "[2/3] Building Python environment..."
echo "[2/4] Building Python environment..."
bash "$SCRIPT_DIR/build-python-env.sh"
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
@@ -71,8 +71,34 @@ fi
echo "Python environment ready."
echo ""
# Step 3: Package with electron-builder
echo "[3/3] Packaging with electron-builder..."
# Step 3: Snapshot source directories for packaging
echo "[3/4] Snapshotting source directories..."
STAGING_DIR="$PROJECT_ROOT/electron/build-staging"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
rsync -a \
--exclude='__pycache__' --exclude='**/__pycache__' \
--exclude='*.pyc' --exclude='.venv' \
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
rsync -a \
--exclude='__pycache__' --exclude='**/__pycache__' \
--exclude='*.pyc' --exclude='.venv' --exclude='**/.venv' \
--exclude='**/node_modules' \
"$PROJECT_ROOT/debugger/" "$STAGING_DIR/debugger/"
rsync -a "$PROJECT_ROOT/frontend/dist/" "$STAGING_DIR/frontend/"
echo ""
printf '\033[1;42;97m%s\033[0m\n' "========================================"
printf '\033[1;42;97m%s\033[0m\n' " ✅ SOURCE SNAPSHOT COMPLETE "
printf '\033[1;42;97m%s\033[0m\n' " It is now safe to modify your codebase."
printf '\033[1;42;97m%s\033[0m\n' "========================================"
echo ""
# Step 4: Package with electron-builder
echo "[4/4] Packaging with electron-builder..."
cd "$PROJECT_ROOT/electron"
npm install
@@ -90,6 +116,8 @@ else
fi
fi
rm -rf "$PROJECT_ROOT/electron/build-staging"
echo ""
echo "========================================"
echo " Build Complete!"