[eric] production push 1.0.24: removed non-Claude MCP upfront-load warning (context-cost only, zero functionality impact), fixed fullscreen

+ webview popup dark-screen via new-window → exitFullscreen so parent stays interactive, ErrorSlime extracted to shared component and
  rendered on all chat error cards (Network / Servers maxed / Plan limit / Subscription), Network issue classifier tightened to real errno
  codes + retry CTA removed since edit_message-based replay was truncating successful tool history — copy now directs user to send-new-message
   once WS auto-reconnects
This commit is contained in:
ciregenz
2026-04-21 15:11:56 -07:00
parent b9f7698b0c
commit 11b07bde28
14 changed files with 505 additions and 321 deletions
+1
View File
@@ -1,6 +1,7 @@
.DS_Store
.worktrees
.env
.local-stash/
backend/data/**
!backend/data/outputs/
!backend/data/outputs/*.json
+302 -162
View File
@@ -53,6 +53,58 @@ def _delete_session_file(session_id: str):
os.remove(path)
# Patterns that indicate an upstream transient problem (overload / rate limit /
# infra blip) — safe to silently retry with backoff. Checked against the
# stringified exception from claude_agent_sdk / Claude CLI.
_TRANSIENT_CAPACITY_PATTERNS = re.compile(
r"(?:\b(?:429|500|502|503|504|529)\b"
r"|overloaded"
r"|service\s+(?:temporarily\s+)?unavailable"
r"|at\s+capacity"
r"|try\s+again\s+shortly"
r"|internal\s+server\s+error"
r"|rate[_\s-]?limit(?:_error)?"
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
r"|upstream\s+connect\s+error)",
re.IGNORECASE,
)
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
# auth). Must NOT retry — upgrading or reauthing is required.
_NON_TRANSIENT_PATTERNS = re.compile(
r"(?:usage\s+cap\s+exceeded"
r"|reached\s+your\s+OpenSwarm.*plan\s+limit"
r"|no\s+active\s+subscription"
r"|subscription\s+(?:canceled|past_due)"
r"|invalid.*token"
r"|missing\s+bearer\s+token"
r"|401|403)",
re.IGNORECASE,
)
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic
# "Command failed with exit code 1 / Check stderr output for details" —
# the real cause (rate_limit_error / No pool capacity available / 429
# / overloaded) only surfaces in the subprocess's stderr stream, which
# we capture via the SDK's `stderr` callback and pass in as extra_text.
# Classify against both so we catch capacity errors regardless of which
# channel carried the message.
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
if _NON_TRANSIENT_PATTERNS.search(combined):
return False
if _TRANSIENT_CAPACITY_PATTERNS.search(combined):
return True
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity
# available. Try again shortly.") — matches the capacity family too.
if re.search(r"no\s+pool\s+capacity", combined, re.IGNORECASE):
return True
return False
def _load_all_session_data() -> list[tuple[str, dict]]:
results = []
if not os.path.exists(SESSIONS_DIR):
@@ -1029,11 +1081,27 @@ class AgentManager:
api_type = _api_type_for_session
session.provider = api_type
# Capture the Claude CLI's stderr into a buffer so the retry
# classifier can see the real cause of a process crash (e.g.
# "No pool capacity available" from the OpenSwarm proxy, or the
# Anthropic SDK's 429/overloaded error body). Without this the
# SDK's ProcessError only stringifies to "Command failed with
# exit code 1 / Check stderr output for details", which masks
# transient capacity issues.
_stderr_buffer: list[str] = []
def _stderr_cb(line: str) -> None:
_stderr_buffer.append(line)
# Cap the buffer so a runaway subprocess can't balloon RAM.
if len(_stderr_buffer) > 500:
del _stderr_buffer[:250]
options_kwargs = {
"model": resolved_model,
"max_buffer_size": 5 * 1024 * 1024,
"permission_mode": "default",
"can_use_tool": can_use_tool,
"stderr": _stderr_cb,
"hooks": {
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
"PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])],
@@ -1230,193 +1298,265 @@ class AgentManager:
stream_block_index_map = {}
_turn_number = 0
_first_event = True
# True between the first non-ResultMessage of a turn and the
# following ResultMessage; False at turn boundaries. The retry
# layer below only retries at boundaries — resuming mid-turn via
# sdk_session_id would risk duplicating user-visible output.
_current_turn_emitted = False
async for message in query(
prompt=prompt_stream(),
options=options,
):
if _first_event:
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
_first_event = False
# Silently absorb transient upstream capacity errors (429/500/503/
# 529/overloaded/network blips) by waiting with exponential
# backoff and restarting the query with resume=sdk_session_id.
# The session keeps its conversation state across retries so the
# user just sees a pause, not a red error card. Hard errors
# (auth, plan limit, invalid args) fall through to the existing
# error handler unchanged.
_CAPACITY_BACKOFFS = [5, 15, 45, 90, 180]
# Log system messages (MCP server status, errors, etc.)
if isinstance(message, SystemMessage):
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
async def _run_streaming_turn():
nonlocal stream_text_msg_id, stream_tool_msg_ids_ordered, stream_block_index_map
nonlocal _turn_number, _first_event, _current_turn_emitted
async for message in query(
prompt=prompt_stream(),
options=options,
):
if isinstance(message, ResultMessage):
_current_turn_emitted = False
else:
_current_turn_emitted = True
if isinstance(message, StreamEvent):
event = message.event
event_type = event.get("type")
if _first_event:
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
_first_event = False
if event_type == "content_block_start":
block = event.get("content_block", {})
index = event.get("index")
block_type = block.get("type")
# Log system messages (MCP server status, errors, etc.)
if isinstance(message, SystemMessage):
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
if block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
if isinstance(message, StreamEvent):
event = message.event
event_type = event.get("type")
if event_type == "content_block_start":
block = event.get("content_block", {})
index = event.get("index")
block_type = block.get("type")
if block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
await ws_manager.send_to_session(session_id, "agent:stream_start", {
"session_id": session_id,
"message_id": stream_text_msg_id,
"role": "assistant",
})
stream_block_index_map[index] = stream_text_msg_id
elif block_type == "thinking":
# Reasoning trace from thinking-capable models
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
# with extended thinking). Rendered as a
# collapsible "thinking" message in the UI via
# the existing stream infrastructure — the
# frontend already handles role="thinking" for
# the DynamicIsland/agent card rendering.
thinking_msg_id = uuid4().hex
stream_block_index_map[index] = thinking_msg_id
await ws_manager.send_to_session(session_id, "agent:stream_start", {
"session_id": session_id,
"message_id": stream_text_msg_id,
"role": "assistant",
"message_id": thinking_msg_id,
"role": "thinking",
})
stream_block_index_map[index] = stream_text_msg_id
elif block_type == "thinking":
# Reasoning trace from thinking-capable models
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
# with extended thinking). Rendered as a
# collapsible "thinking" message in the UI via
# the existing stream infrastructure — the
# frontend already handles role="thinking" for
# the DynamicIsland/agent card rendering.
thinking_msg_id = uuid4().hex
stream_block_index_map[index] = thinking_msg_id
await ws_manager.send_to_session(session_id, "agent:stream_start", {
elif block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids_ordered.append(tool_msg_id)
stream_block_index_map[index] = tool_msg_id
await ws_manager.send_to_session(session_id, "agent:stream_start", {
"session_id": session_id,
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": block.get("name", ""),
})
elif event_type == "content_block_delta":
index = event.get("index")
delta = event.get("delta", {})
delta_type = delta.get("type")
msg_id = stream_block_index_map.get(index)
if msg_id and delta_type == "text_delta":
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("text", ""),
})
elif msg_id and delta_type == "thinking_delta":
# Thinking content streams as thinking_delta
# with a "thinking" field (not "text")
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("thinking", ""),
})
elif msg_id and delta_type == "input_json_delta":
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("partial_json", ""),
})
elif event_type == "content_block_stop":
index = event.get("index")
msg_id = stream_block_index_map.get(index)
if msg_id and msg_id != stream_text_msg_id:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": msg_id,
})
elif event_type == "message_stop":
if stream_text_msg_id:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": stream_text_msg_id,
})
elif isinstance(message, AssistantMessage):
content_parts = []
thinking_parts = []
tool_uses = []
for block in message.content:
if isinstance(block, ThinkingBlock):
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
if thinking_text:
thinking_parts.append(thinking_text)
elif isinstance(block, TextBlock):
content_parts.append(block.text)
elif isinstance(block, ToolUseBlock):
tool_uses.append({
"id": block.id,
"tool": block.name,
"input": block.input,
})
# Emit thinking trace as a separate message so the
# frontend can render it as a collapsible reasoning
# bubble (GPT-5.3 Codex, Gemini 3 Pro/Flash).
if thinking_parts:
thinking_msg = Message(
role="thinking",
content="\n".join(thinking_parts),
branch_id=session.active_branch_id,
)
session.messages.append(thinking_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message_id": thinking_msg_id,
"role": "thinking",
"message": thinking_msg.model_dump(mode="json"),
})
elif block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids_ordered.append(tool_msg_id)
stream_block_index_map[index] = tool_msg_id
await ws_manager.send_to_session(session_id, "agent:stream_start", {
if content_parts:
asst_msg = Message(
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", {
"session_id": session_id,
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": block.get("name", ""),
"message": asst_msg.model_dump(mode="json"),
})
elif event_type == "content_block_delta":
index = event.get("index")
delta = event.get("delta", {})
delta_type = delta.get("type")
msg_id = stream_block_index_map.get(index)
if msg_id and delta_type == "text_delta":
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
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, 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,
"message_id": msg_id,
"delta": delta.get("text", ""),
})
elif msg_id and delta_type == "thinking_delta":
# Thinking content streams as thinking_delta
# with a "thinking" field (not "text")
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("thinking", ""),
})
elif msg_id and delta_type == "input_json_delta":
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
"session_id": session_id,
"message_id": msg_id,
"delta": delta.get("partial_json", ""),
"message": tool_msg.model_dump(mode="json"),
})
elif event_type == "content_block_stop":
index = event.get("index")
msg_id = stream_block_index_map.get(index)
if msg_id and msg_id != stream_text_msg_id:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": msg_id,
})
_turn_number += 1
_analytics("turn.completed", {
"turn_number": _turn_number,
"tool_calls_in_turn": len(tool_uses),
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
elif event_type == "message_stop":
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
elif isinstance(message, ResultMessage):
session.sdk_session_id = getattr(message, "session_id", None)
cost = getattr(message, "total_cost_usd", None)
if cost is not None:
session.cost_usd = cost
await ws_manager.send_to_session(session_id, "agent:cost_update", {
"session_id": session_id,
"cost_usd": session.cost_usd,
})
# Extract token usage from ResultMessage
usage = getattr(message, "usage", None) or {}
if isinstance(usage, dict):
inp = usage.get("input_tokens", 0) or 0
out = usage.get("output_tokens", 0) or 0
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
cache_read = usage.get("cache_read_input_tokens", 0) or 0
session.tokens["input"] = inp + cache_create + cache_read
session.tokens["output"] = out
capacity_retry_attempt = 0
while True:
try:
await _run_streaming_turn()
break
except Exception as e:
stderr_snapshot = "\n".join(_stderr_buffer[-50:])
if (
_is_transient_capacity_error(e, extra_text=stderr_snapshot)
and capacity_retry_attempt < len(_CAPACITY_BACKOFFS)
):
wait = _CAPACITY_BACKOFFS[capacity_retry_attempt]
capacity_retry_attempt += 1
mid_stream = _current_turn_emitted
logger.warning(
f"Transient upstream error on session {session_id} "
f"(attempt {capacity_retry_attempt}/{len(_CAPACITY_BACKOFFS)}, "
f"mid_stream={mid_stream}); sleeping {wait}s before retry. "
f"exc={e!r} stderr_tail={stderr_snapshot[-400:]!r}"
)
# Finalize any in-flight stream messages so the UI
# doesn't leave them pinned as "still streaming" while
# we wait and restart. On resume the CLI re-runs the
# last turn from scratch (Anthropic doesn't persist
# in-progress responses), so the partial assistant
# text / tool call we emitted is now orphaned — cap
# it with stream_end and start the fresh turn under a
# new message id.
if stream_text_msg_id:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": stream_text_msg_id,
})
elif isinstance(message, AssistantMessage):
content_parts = []
thinking_parts = []
tool_uses = []
for block in message.content:
if isinstance(block, ThinkingBlock):
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
if thinking_text:
thinking_parts.append(thinking_text)
elif isinstance(block, TextBlock):
content_parts.append(block.text)
elif isinstance(block, ToolUseBlock):
tool_uses.append({
"id": block.id,
"tool": block.name,
"input": block.input,
stream_text_msg_id = None
for _tool_msg_id in stream_tool_msg_ids_ordered:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": _tool_msg_id,
})
# Emit thinking trace as a separate message so the
# frontend can render it as a collapsible reasoning
# bubble (GPT-5.3 Codex, Gemini 3 Pro/Flash).
if thinking_parts:
thinking_msg = Message(
role="thinking",
content="\n".join(thinking_parts),
branch_id=session.active_branch_id,
)
session.messages.append(thinking_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": thinking_msg.model_dump(mode="json"),
})
if content_parts:
asst_msg = Message(
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", {
"session_id": session_id,
"message": asst_msg.model_dump(mode="json"),
})
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, 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,
"message": tool_msg.model_dump(mode="json"),
})
_turn_number += 1
_analytics("turn.completed", {
"turn_number": _turn_number,
"tool_calls_in_turn": len(tool_uses),
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
elif isinstance(message, ResultMessage):
session.sdk_session_id = getattr(message, "session_id", None)
cost = getattr(message, "total_cost_usd", None)
if cost is not None:
session.cost_usd = cost
await ws_manager.send_to_session(session_id, "agent:cost_update", {
"session_id": session_id,
"cost_usd": session.cost_usd,
})
# Extract token usage from ResultMessage
usage = getattr(message, "usage", None) or {}
if isinstance(usage, dict):
inp = usage.get("input_tokens", 0) or 0
out = usage.get("output_tokens", 0) or 0
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
cache_read = usage.get("cache_read_input_tokens", 0) or 0
session.tokens["input"] = inp + cache_create + cache_read
session.tokens["output"] = out
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
_current_turn_emitted = False
await asyncio.sleep(wait)
_stderr_buffer.clear()
if session.sdk_session_id:
options_kwargs["resume"] = session.sdk_session_id
options = ClaudeAgentOptions(**options_kwargs)
continue
raise
session.status = "completed"
except asyncio.CancelledError:
+1 -1
View File
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
logger = logging.getLogger(__name__)
APP_VERSION = "1.0.23"
APP_VERSION = "1.0.24"
_heartbeat_task: asyncio.Task | None = None
+11
View File
@@ -526,6 +526,14 @@ app.on('web-contents-created', (_event, contents) => {
action: 'allow',
overrideBrowserWindowOptions: {
parent: mainWindow || undefined,
width: 520,
height: 680,
center: true,
fullscreen: false,
fullscreenable: false,
resizable: true,
minimizable: false,
maximizable: false,
},
};
});
@@ -533,6 +541,9 @@ app.on('web-contents-created', (_event, contents) => {
contents.on('did-create-window', (childWindow) => {
if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) {
childWindow.setParentWindow(mainWindow);
// Belt-and-suspenders: if the parent was fullscreen when window.open
// fired, Electron can still spawn the child fullscreen. Force it back.
if (childWindow.isFullScreen()) childWindow.setFullScreen(false);
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.23",
"version": "1.0.24",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
@@ -0,0 +1,21 @@
import React from 'react';
/** Cute slime with × eyes and a red error badge — error / warning illustration. */
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
<path
d="M4 20 Q4 7 14 7 Q24 7 24 20 Q22 22 19 21.5 Q16 23 14 22 Q12 23 9 21.5 Q6 22 4 20Z"
fill="#E8927A"
/>
<ellipse cx="11" cy="11" rx="3.5" ry="2" fill="#F0A68E" opacity="0.6" />
<line x1="9.5" y1="13" x2="11.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="11.5" y1="13" x2="9.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="16.5" y1="13" x2="18.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="18.5" y1="13" x2="16.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<path d="M12 18.5 Q14 17.5 16 18.5" stroke="#4a2020" strokeWidth="1" strokeLinecap="round" fill="none" />
<circle cx="22" cy="5" r="4" fill="#ef4444" stroke="rgba(0,0,0,0.15)" strokeWidth="0.5" />
<text x="22" y="6.8" textAnchor="middle" fontSize="5.5" fill="white" fontWeight="bold" fontFamily="sans-serif">!</text>
</svg>
);
export default ErrorSlime;
@@ -43,6 +43,7 @@ import { fetchOutputs } from '@/shared/state/outputsSlice';
import { setInstalling } from '@/shared/state/updateSlice';
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ErrorSlime } from '@/app/components/ErrorSlime';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 400;
@@ -58,32 +59,6 @@ const CUSTOMIZATION_ITEMS = [
const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
/** Cute slime with × eyes and a red error badge — warning banner icon. */
const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
{/* Body — smooth dome blob sitting on a wobbly base */}
<path
d="M4 20 Q4 7 14 7 Q24 7 24 20 Q22 22 19 21.5 Q16 23 14 22 Q12 23 9 21.5 Q6 22 4 20Z"
fill="#E8927A"
/>
{/* Subtle highlight on top of the dome */}
<ellipse cx="11" cy="11" rx="3.5" ry="2" fill="#F0A68E" opacity="0.6" />
{/* × left eye */}
<line x1="9.5" y1="13" x2="11.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="11.5" y1="13" x2="9.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
{/* × right eye */}
<line x1="16.5" y1="13" x2="18.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
<line x1="18.5" y1="13" x2="16.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
{/* Small frown */}
<path d="M12 18.5 Q14 17.5 16 18.5" stroke="#4a2020" strokeWidth="1" strokeLinecap="round" fill="none" />
{/* Red error badge — top right */}
<circle cx="22" cy="5" r="4" fill="#ef4444" stroke="rgba(0,0,0,0.15)" strokeWidth="0.5" />
<text x="22" y="6.8" textAnchor="middle" fontSize="5.5" fill="white" fontWeight="bold" fontFamily="sans-serif">!</text>
</svg>
);
const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
+39 -23
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useMemo, useState, useCallback } from 'react';
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
@@ -59,18 +59,20 @@ function stringifyContent(content: any): string {
return JSON.stringify(content);
}
const thinkingDotsKeyframes = `
@keyframes thinking-bounce {
0%, 80%, 100% { transform: scale(0); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
const ThinkingBubble: React.FC = () => {
const c = useClaudeTokens();
const shimmerBase = c.text.tertiary;
const shimmerHighlight = c.text.primary;
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<style>{thinkingDotsKeyframes}</style>
<style>{thinkingShimmerKeyframes}</style>
<Box
sx={{
bgcolor: c.bg.surface,
@@ -81,23 +83,25 @@ const ThinkingBubble: React.FC = () => {
boxShadow: c.shadow.sm,
display: 'flex',
alignItems: 'center',
gap: '4px',
minHeight: 36,
}}
>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: c.text.tertiary,
animation: 'thinking-bounce 1.4s infinite ease-in-out both',
animationDelay: `${i * 0.16}s`,
}}
/>
))}
<Box
component="span"
sx={{
fontSize: '0.85rem',
fontWeight: 500,
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'thinking-shimmer 2s linear infinite',
}}
>
Thinking
</Box>
</Box>
</Box>
);
@@ -336,13 +340,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
setShowScrollButton(false);
}, []);
useLayoutEffect(() => {
if (isAtBottomRef.current) {
const scrollRafRef = useRef<number | null>(null);
useEffect(() => {
if (!isAtBottomRef.current) return;
if (scrollRafRef.current != null) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
if (!isAtBottomRef.current) return;
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
});
}, [session?.messages.length, session?.streamingMessage?.content]);
useEffect(() => () => {
if (scrollRafRef.current != null) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
}, []);
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;
// Sending a message is a clear intent signal: the user wants to see
@@ -205,27 +205,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
const toolItems = useAppSelector((state) => state.tools.items);
// Count the total number of enabled MCP tool permissions (non-deny) across
// all enabled MCP servers. Used to warn users before they switch to a
// non-Claude model that can't leverage the deferred-tool pool — those
// models get every schema upfront and may exhaust context fast.
const enabledMcpToolCount = useMemo(() => {
let count = 0;
for (const id in toolItems) {
const t = toolItems[id];
if (t.enabled && t.mcp_config && t.tool_permissions) {
for (const name in t.tool_permissions) {
if (t.tool_permissions[name] !== 'deny') count++;
}
}
}
return count;
}, [toolItems]);
// One-time dismissible warning when picking a non-Claude model with many MCPs.
const [mcpWarningOpen, setMcpWarningOpen] = useState(false);
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
const MCP_WARNING_THRESHOLD = 20;
// Build flat model list with provider grouping. Group names come from the
// backend's /agents/models response verbatim — "OpenSwarm Pro" for
@@ -1166,19 +1145,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
onProviderChange(providerMap[provLower] || provLower);
}
// OpenSwarm Pro routes Claude models through our proxy —
// they're still Claude, so they support the deferred tool
// loader. Only warn when the user picks a truly non-Claude
// provider (GPT/Gemini/etc).
const provLowerForWarn = prov.toLowerCase();
const isClaudeProvider = provLowerForWarn === 'anthropic' || provLowerForWarn === 'openswarm pro';
if (!isClaudeProvider && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
}
} catch {}
}
setModelAnchor(null);
}}
>
@@ -1449,40 +1415,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
</Box>
</Modal>
<Snackbar
open={mcpWarningOpen}
autoHideDuration={12000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
onClose={(_, reason) => {
if (reason === 'clickaway') return;
setMcpWarningOpen(false);
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
}
} catch { /* ignore */ }
}}
>
<Alert
severity="warning"
variant="filled"
onClose={() => {
setMcpWarningOpen(false);
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
}
} catch { /* ignore */ }
}}
sx={{ fontSize: '0.78rem', maxWidth: 520 }}
>
Non-Claude models don't support the deferred tool loader — all
{' '}{enabledMcpToolCount} MCP tool schemas will be sent upfront,
which may exhaust context on long sessions. Disable MCPs you
don't need in Settings Tools.
</Alert>
</Snackbar>
</Box>
);
});
@@ -23,6 +23,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
import ViewBubble from './ViewBubble';
import PlanPicker from '@/app/components/PlanPicker';
import { ErrorSlime } from '@/app/components/ErrorSlime';
const streamingCursorKeyframes = `
@keyframes blink-cursor {
@@ -110,14 +111,20 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
ctaAction: 'settings',
};
}
// Network issues (keep last so it doesn't swallow the specific cases above)
if (/ECONNREFUSED|ENETUNREACH|fetch failed|ETIMEDOUT|network|Could not reach/i.test(text)) {
// Genuine, hard network failures only. The bare word `network` used to
// match anything mentioning "network" (Python traces, MCP tool output,
// ffmpeg lines, etc.), and `fetch failed` / `ETIMEDOUT` alone fire for
// transient upstream blips the backend now silently retries — surfacing
// a card for those just confuses the user. So: require the specific
// errno codes at word boundaries, and only match `fetch failed` when
// paired with a concrete cause so we don't swallow every Node-level
// transient. The backend's capacity/transient retry layer handles the
// rest without ever reaching this classifier.
if (/\b(?:ECONNREFUSED|ENETUNREACH|ENOTFOUND|EAI_AGAIN)\b|Could\s+not\s+reach\s+OpenSwarm|Unable\s+to\s+connect\s+to\s+OpenSwarm/i.test(text)) {
return {
kind: 'network',
title: 'Network issue',
detail: "Can't reach the OpenSwarm service. Check your internet connection and try again.",
ctaLabel: 'Try again',
ctaAction: 'retry',
title: 'Connection issue',
detail: "We couldn't reach the service. Once your connection is back, send a new message to continue.",
};
}
return null;
@@ -662,6 +669,17 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
? parseElementContext(rawText)
: { userMessage: rawText, elements: [] };
const renderedMarkdown = useMemo(() => (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
), [rawText]);
// Detect friendly OpenSwarm / upstream errors and render a card instead of
// raw "API Error: ..." text. Checks both the wrapped format the Claude CLI
// uses ("API Error: NNN …") and the raw JSON body.
@@ -871,9 +889,12 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
gap: 0.7,
}}
>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
{openswarmError.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ErrorSlime size={22} />
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
{openswarmError.title}
</Typography>
</Box>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5 }}>
{openswarmError.detail}
</Typography>
@@ -914,14 +935,22 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
</Box>
) : (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming ? (
<Box
component="div"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 'inherit',
lineHeight: 'inherit',
color: 'inherit',
}}
>
{rawText}
</Box>
) : (
renderedMarkdown
)}
{isStreaming && <StreamingCursor />}
</>
)}
@@ -245,6 +245,16 @@ const BrowserCard: React.FC<Props> = ({
}
};
// When a webview popup spawns while the app is in document fullscreen,
// Chromium's compositor shifts to the popup and the parent surface goes
// black with no fullscreenchange event. Drop fullscreen first so the
// popup renders normally and stays interactive.
const onNewWindow = () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
};
wv.addEventListener('did-navigate', onNavigate);
wv.addEventListener('did-navigate-in-page', onNavigate);
wv.addEventListener('page-title-updated', onTitleUpdate);
@@ -252,6 +262,7 @@ const BrowserCard: React.FC<Props> = ({
wv.addEventListener('did-stop-loading', onLoadStop);
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
wv.addEventListener('ipc-message', onIpcMessage as any);
wv.addEventListener('new-window', onNewWindow as any);
cleanups.push(() => {
unregisterWebview(browserId, tabId);
@@ -262,6 +273,7 @@ const BrowserCard: React.FC<Props> = ({
wv.removeEventListener('did-stop-loading', onLoadStop);
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
wv.removeEventListener('ipc-message', onIpcMessage as any);
wv.removeEventListener('new-window', onNewWindow as any);
});
}
@@ -376,7 +376,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
boxShadow: c.shadow.lg,
padding: isExpanded ? '4px' : '6px',
userSelect: 'none' as const,
overflow: inputOpen ? 'visible' : 'hidden',
overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
width: viewPickerOpen ? 480 : isExpanded ? 360 : undefined,
}}
>
@@ -618,8 +618,10 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
aria-label="New Agent"
data-onboarding="new-agent-button"
tabIndex={0}
onClick={onNewAgent}
onAnimationEnd={newAgentBounce ? onNewAgentBounceEnd : undefined}
onClick={() => {
if (newAgentBounce) onNewAgentBounceEnd?.();
onNewAgent();
}}
sx={{
display: 'flex',
alignItems: 'center',
@@ -634,10 +636,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
'&:hover': { bgcolor: c.accent.hover },
'&:active': { bgcolor: c.accent.pressed },
...(newAgentBounce && {
animation: 'new-agent-bounce 0.7s ease-in-out 4',
animation: 'new-agent-bounce 1.6s ease-out infinite',
'@keyframes new-agent-bounce': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-8px)' },
'0%': { transform: 'translateY(0)' },
'15%': { transform: 'translateY(-10px)' },
'30%': { transform: 'translateY(0)' },
'42%': { transform: 'translateY(-4px)' },
'55%': { transform: 'translateY(0)' },
'100%': { transform: 'translateY(0)' },
},
}),
}}
+57 -16
View File
@@ -36,8 +36,8 @@ class WebSocketManager {
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private listeners: Map<string, Set<(data: any) => void>> = new Map();
private deltaBuffer: Map<string, { sessionId: string; messageId: string; accumulated: string }> = new Map();
private flushScheduled = false;
private interpolatorState: Map<string, { sessionId: string; messageId: string; targetText: string; displayedLength: number }> = new Map();
private interpolatorRafId: number | null = null;
constructor(url: string, options?: WSManagerOptions) {
this.url = url;
@@ -45,24 +45,60 @@ class WebSocketManager {
}
private bufferDelta(sessionId: string, messageId: string, delta: string) {
const existing = this.deltaBuffer.get(messageId);
const existing = this.interpolatorState.get(messageId);
if (existing) {
existing.accumulated += delta;
existing.targetText += delta;
} else {
this.deltaBuffer.set(messageId, { sessionId, messageId, accumulated: delta });
}
if (!this.flushScheduled) {
this.flushScheduled = true;
requestAnimationFrame(() => this.flushDeltas());
this.interpolatorState.set(messageId, { sessionId, messageId, targetText: delta, displayedLength: 0 });
}
this.scheduleInterpolator();
}
private flushDeltas() {
this.flushScheduled = false;
for (const [, { sessionId, messageId, accumulated }] of this.deltaBuffer) {
store.dispatch(streamDelta({ sessionId, messageId, delta: accumulated }));
private scheduleInterpolator() {
if (this.interpolatorRafId != null) return;
this.interpolatorRafId = requestAnimationFrame(() => this.tickInterpolator());
}
// Drain each message's pending text at a paced, roughly-uniform rate so
// bursty server emissions paint as a smooth stream of characters instead of
// visible chunks. Rate adapts to backlog: small backlog → ~2 chars/frame
// (~120cps, typewriter feel); large backlog → up to 40 chars/frame so we
// catch up fast without pinning the main thread.
private tickInterpolator() {
this.interpolatorRafId = null;
let workRemaining = false;
for (const state of this.interpolatorState.values()) {
const remaining = state.targetText.length - state.displayedLength;
if (remaining <= 0) continue;
const step = Math.min(Math.max(Math.ceil(remaining / 6), 2), 40);
const nextLength = Math.min(state.displayedLength + step, state.targetText.length);
const deltaSlice = state.targetText.slice(state.displayedLength, nextLength);
state.displayedLength = nextLength;
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: deltaSlice }));
if (state.displayedLength < state.targetText.length) workRemaining = true;
}
if (workRemaining) this.scheduleInterpolator();
}
// Flush remaining pending text synchronously. Pass a messageId to flush
// only that stream (used on stream_end so the tail isn't paced).
private flushInterpolator(messageId?: string) {
const drain = (state: { sessionId: string; messageId: string; targetText: string; displayedLength: number }) => {
if (state.displayedLength >= state.targetText.length) return;
const tail = state.targetText.slice(state.displayedLength);
state.displayedLength = state.targetText.length;
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: tail }));
};
if (messageId) {
const state = this.interpolatorState.get(messageId);
if (state) {
drain(state);
this.interpolatorState.delete(messageId);
}
} else {
for (const state of this.interpolatorState.values()) drain(state);
this.interpolatorState.clear();
}
this.deltaBuffer.clear();
}
connect() {
@@ -97,6 +133,11 @@ class WebSocketManager {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.interpolatorRafId != null) {
cancelAnimationFrame(this.interpolatorRafId);
this.interpolatorRafId = null;
}
this.flushInterpolator();
this.ws?.close();
this.ws = null;
}
@@ -146,7 +187,7 @@ class WebSocketManager {
case 'agent:message':
if (session_id && data.message) {
if (this.deltaBuffer.size > 0) this.flushDeltas();
if (this.interpolatorState.size > 0) this.flushInterpolator();
store.dispatch(addMessage({ sessionId: session_id, message: data.message }));
}
break;
@@ -170,7 +211,7 @@ class WebSocketManager {
case 'agent:stream_end':
if (session_id && data.message_id) {
if (this.deltaBuffer.size > 0) this.flushDeltas();
this.flushInterpolator(data.message_id);
store.dispatch(streamEnd({
sessionId: session_id,
messageId: data.message_id,
File diff suppressed because one or more lines are too long