[eric] merge haik/updates-v1 + bump to v1.0.12

This commit is contained in:
ciregenz
2026-03-23 18:17:57 -07:00
21 changed files with 1420 additions and 581 deletions
-8
View File
@@ -229,14 +229,6 @@ Please open an issue first for larger changes so we can discuss the approach.
<br>
## Community
- [Twitter / X](https://twitter.com/openswarm_ai)
- [Discord](https://discord.gg/openswarm)
- [Website](https://openswarm.ai)
<br>
## License
MIT — see [LICENSE](LICENSE) for details.
+47 -3
View File
@@ -1070,7 +1070,7 @@ class AgentManager:
self.tasks[session_id] = task
async def stop_agent(self, session_id: str):
"""Stop a running agent."""
"""Stop a running agent and all its browser-agent children."""
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
@@ -1081,6 +1081,13 @@ class AgentManager:
session = self.sessions.get(session_id)
if session:
for req in list(session.pending_approvals):
ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"})
session.pending_approvals = []
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
session.status = "stopped"
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
@@ -1088,6 +1095,13 @@ class AgentManager:
"session": session.model_dump(mode="json"),
})
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
]
for child in children:
await self.stop_agent(child.id)
def handle_approval(self, request_id: str, decision: dict):
"""Resolve a pending HITL approval."""
ws_manager.resolve_approval(request_id, decision)
@@ -1160,6 +1174,13 @@ class AgentManager:
})
session.sdk_session_id = None
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, new_content,
images=target_msg.images,
@@ -1321,7 +1342,14 @@ class AgentManager:
async def close_session(self, session_id: str) -> None:
"""Close a session: pause the agent if running, persist to JSON file,
and remove from in-memory state."""
and remove from in-memory state. Also stops browser-agent children."""
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
]
for child in children:
await self.stop_agent(child.id)
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
@@ -1337,8 +1365,14 @@ class AgentManager:
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
session.closed_at = datetime.now()
for req in list(session.pending_approvals):
ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"})
session.pending_approvals = []
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = self._build_search_text(session)
@@ -1361,7 +1395,15 @@ class AgentManager:
logger.info(f"Session {session_id} closed and persisted")
async def delete_session(self, session_id: str) -> None:
"""Permanently delete a session: remove from memory and JSON file."""
"""Permanently delete a session: remove from memory and JSON file.
Also stops browser-agent children first."""
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
]
for child in children:
await self.stop_agent(child.id)
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
@@ -1455,6 +1497,8 @@ class AgentManager:
for session_id, session in list(self.sessions.items()):
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
for req in list(session.pending_approvals):
ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"})
session.pending_approvals = []
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = self._build_search_text(session)
+8 -3
View File
@@ -61,10 +61,12 @@ class ConnectionManager:
pass
async def send_approval_request(
self, session_id: str, request_id: str, tool_name: str, tool_input: dict
self, session_id: str, request_id: str, tool_name: str, tool_input: dict,
timeout: float = 600.0,
) -> dict:
"""Send an approval request and wait for the user's response.
Returns the approval decision dict."""
Returns the approval decision dict. Times out after *timeout* seconds
(default 10 minutes) to prevent permanently stuck agents."""
future = asyncio.get_event_loop().create_future()
self.pending_futures[request_id] = future
@@ -75,8 +77,11 @@ class ConnectionManager:
})
try:
result = await future
result = await asyncio.wait_for(future, timeout=timeout)
return result
except asyncio.TimeoutError:
logger.warning("Approval %s for session %s timed out after %ss", request_id, session_id, timeout)
return {"behavior": "deny", "message": "Approval timed out"}
finally:
self.pending_futures.pop(request_id, None)
+1 -1
View File
@@ -172,7 +172,7 @@ async def generate_name(dashboard_id: str):
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
model="claude-haiku-4-20250414",
model="claude-haiku-4-5-20251001",
max_tokens=30,
system=system,
messages=[{"role": "user", "content": user_content}],
+1 -1
View File
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
"haiku": "claude-haiku-4-20250414",
"haiku": "claude-haiku-4-5-20251001",
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.10",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.10",
"version": "1.0.11",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.10",
"version": "1.0.12",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
@@ -0,0 +1,993 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import SearchIcon from '@mui/icons-material/Search';
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Check';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
handleApproval,
stopAgent,
dismissAgentNotification,
dismissAllFinishedNotifications,
ApprovalRequest,
AgentSession,
HistorySession,
} from '@/shared/state/agentsSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded';
interface SessionApprovalGroup {
sessionId: string;
sessionName: string;
approvals: ApprovalRequest[];
}
type TrackedAgent = {
id: string;
name: string;
status: AgentSession['status'] | string;
dashboardId?: string;
};
const STATUS_CONFIG: Record<string, { label: string; tokenKey?: string }> = {
running: { label: 'Running', tokenKey: 'success' },
waiting_approval: { label: 'Waiting', tokenKey: 'warning' },
completed: { label: 'Done', tokenKey: 'success' },
error: { label: 'Error', tokenKey: 'error' },
stopped: { label: 'Stopped', tokenKey: 'info' },
};
// ---------------------------------------------------------------------------
// Spring configs
// ---------------------------------------------------------------------------
const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 };
const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 };
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const StatusDot: React.FC<{ status: string; c: ReturnType<typeof useClaudeTokens> }> = ({ status, c }) => {
const cfg = STATUS_CONFIG[status];
const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
const isActive = status === 'running';
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: color,
flexShrink: 0,
opacity: 0.8,
...(isActive && {
animation: 'islandPulse 2s ease-in-out infinite',
'@keyframes islandPulse': {
'0%, 100%': { opacity: 0.8, transform: 'scale(1)' },
'50%': { opacity: 0.4, 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.border.subtle },
transition: 'background-color 0.15s',
minHeight: 34,
}}
>
<StatusDot status={agent.status} c={c} />
<Typography
sx={{
fontSize: '0.78rem',
fontWeight: 500,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{agent.name}
</Typography>
<Typography
sx={{
fontSize: '0.6rem',
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.04em',
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.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }}
>
<StopCircleOutlinedIcon sx={{ fontSize: 15 }} />
</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.border.subtle } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
};
// ---------------------------------------------------------------------------
// Compact activity indicator — subtle breathing dot
// ---------------------------------------------------------------------------
const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.text.tertiary,
flexShrink: 0,
animation: 'subtlePulse 2.2s ease-in-out infinite',
'@keyframes subtlePulse': {
'0%, 100%': { opacity: 0.6, transform: 'scale(1)' },
'50%': { opacity: 1, transform: 'scale(1.15)' },
},
}}
/>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DynamicIsland: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const islandRef = useRef<HTMLDivElement>(null);
const sessions = useAppSelector((state) => state.agents.sessions);
const history = useAppSelector((state) => state.agents.history);
const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds);
const [userExpanded, setUserExpanded] = useState(false);
// ---- Derived data ----
const groups: SessionApprovalGroup[] = useMemo(() => {
const result: SessionApprovalGroup[] = [];
for (const [sessionId, session] of Object.entries(sessions)) {
if (session.pending_approvals?.length > 0) {
result.push({
sessionId,
sessionName: session.name || 'Agent',
approvals: session.pending_approvals,
});
}
}
return result;
}, [sessions]);
const totalApprovals = useMemo(
() => groups.reduce((sum, g) => sum + g.approvals.length, 0),
[groups],
);
const trackedAgents: TrackedAgent[] = useMemo(() => {
const agents = 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);
const trackedIdSet = new Set(trackedIds);
for (const g of groups) {
if (!trackedIdSet.has(g.sessionId)) {
const session = sessions[g.sessionId];
if (session && session.status !== 'draft') {
agents.push({ id: g.sessionId, name: session.name, status: session.status, dashboardId: session.dashboard_id });
}
}
}
return agents;
}, [trackedIds, sessions, history, groups]);
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 hasApprovals = totalApprovals > 0;
const hasAgents = trackedAgents.length > 0;
const hasOnlyQuestionApprovals = useMemo(() => {
if (!hasApprovals) return false;
const allApprovals = groups.flatMap((g) => g.approvals);
return allApprovals.every((a) => a.tool_name === 'AskUserQuestion');
}, [hasApprovals, groups]);
const nonQuestionApprovalCount = useMemo(
() => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0),
[groups],
);
const oldestNonQuestionApproval = useMemo(() => {
const all = groups
.flatMap((g) => g.approvals)
.filter((a) => a.tool_name !== 'AskUserQuestion');
if (all.length === 0) return null;
return all.reduce((oldest, a) =>
a.created_at < oldest.created_at ? a : oldest,
);
}, [groups]);
// ---- Island state machine ----
const islandState: IslandState = useMemo(() => {
if (userExpanded && (hasAgents || hasApprovals)) return 'expanded';
if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded';
if (hasApprovals) return 'compact-actionable';
if (hasAgents) return 'compact';
return 'idle';
}, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]);
useEffect(() => {
if (!hasAgents && !hasApprovals) {
setUserExpanded(false);
}
}, [hasAgents, hasApprovals]);
// ---- Click outside to collapse ----
useEffect(() => {
if (islandState !== 'expanded') return;
const handler = (e: MouseEvent) => {
if (islandRef.current && !islandRef.current.contains(e.target as Node)) {
setUserExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [islandState]);
// ---- Callbacks ----
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
},
[dispatch],
);
const onDeny = useCallback(
(requestId: string, message?: string) => {
dispatch(handleApproval({ requestId, behavior: 'deny', message }));
},
[dispatch],
);
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],
);
const onApproveAllNonQuestion = useCallback(() => {
for (const g of groups) {
for (const req of g.approvals) {
if (req.tool_name !== 'AskUserQuestion') {
dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
}
}
}
}, [dispatch, groups]);
const onDenyAllNonQuestion = useCallback(() => {
for (const g of groups) {
for (const req of g.approvals) {
if (req.tool_name !== 'AskUserQuestion') {
dispatch(handleApproval({ requestId: req.id, behavior: 'deny' }));
}
}
}
}, [dispatch, groups]);
const onClearAllFinished = useCallback(() => {
dispatch(dismissAllFinishedNotifications());
}, [dispatch]);
const handleIslandClick = useCallback(() => {
if (islandState === 'compact' || islandState === 'compact-actionable') {
setUserExpanded(true);
} else if (islandState === 'expanded') {
setUserExpanded(false);
}
}, [islandState]);
// ---- Styling — uses the same neutral palette as the rest of the UI ----
const islandWidth = islandState === 'idle'
? 200
: islandState === 'compact'
? 210
: islandState === 'compact-actionable'
? 310
: 400;
const islandBorderRadius = islandState === 'expanded' ? 14 : 50;
const shadow = islandState === 'idle'
? 'none'
: islandState === 'compact'
? c.shadow.sm
: c.shadow.md;
// ---- Compact summary text ----
const compactText = useMemo(() => {
const parts: string[] = [];
if (activeAgents.length > 0) {
parts.push(`${activeAgents.length} running`);
}
if (finishedAgents.length > 0) {
parts.push(`${finishedAgents.length} done`);
}
return parts.join(' · ') || 'Agents';
}, [activeAgents.length, finishedAgents.length]);
const glowKeyframes = useMemo(() => `
@keyframes approvalGlow {
0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; }
50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; }
}
`, [c.status.warning]);
// ---- Render ----
return (
<>
{islandState === 'compact-actionable' && <style>{glowKeyframes}</style>}
<motion.div
ref={islandRef}
layout
transition={islandState === 'expanded' ? SPRING_LAYOUT : SPRING_BOUNCE}
style={{
position: 'absolute',
left: '50%',
top: 6,
x: '-50%',
zIndex: 9999,
width: islandWidth,
borderRadius: islandBorderRadius,
cursor: islandState === 'expanded' ? 'default' : 'pointer',
// @ts-expect-error -- vendor prefix
WebkitAppRegion: 'no-drag',
}}
onClick={islandState !== 'expanded' && islandState !== 'compact-actionable' ? handleIslandClick : undefined}
>
<motion.div
layout
transition={SPRING_LAYOUT}
style={{
background: c.bg.secondary,
border: islandState === 'compact-actionable'
? `1px solid ${c.status.warning}`
: `0.5px solid ${c.border.medium}`,
borderRadius: islandBorderRadius,
boxShadow: islandState === 'compact-actionable'
? `0 0 8px 1px ${c.status.warning}40`
: shadow,
overflow: 'hidden',
animation: islandState === 'compact-actionable'
? 'approvalGlow 2.5s ease-in-out infinite'
: 'none',
}}
>
<AnimatePresence mode="wait">
{islandState === 'idle' && (
<IdlePill key="idle" c={c} />
)}
{islandState === 'compact' && (
<CompactPill
key="compact"
c={c}
text={compactText}
activeCount={activeAgents.length}
hasApprovals={hasApprovals}
/>
)}
{islandState === 'compact-actionable' && oldestNonQuestionApproval && (
<CompactActionablePill
key="compact-actionable"
c={c}
request={oldestNonQuestionApproval}
remainingCount={nonQuestionApprovalCount}
onApprove={onApprove}
onDeny={onDeny}
onExpand={() => setUserExpanded(true)}
/>
)}
{islandState === 'expanded' && (
<ExpandedCard
key="expanded"
c={c}
groups={groups}
totalApprovals={totalApprovals}
activeAgents={activeAgents}
finishedAgents={finishedAgents}
hasApprovals={hasApprovals}
hasAgents={hasAgents}
onApprove={onApprove}
onDeny={onDeny}
onStopAgent={onStopAgent}
onDismissAgent={onDismissAgent}
onNavigateToDashboard={onNavigateToDashboard}
onClearAllFinished={onClearAllFinished}
onCollapse={() => setUserExpanded(false)}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
</>
);
};
// ---------------------------------------------------------------------------
// Idle pill — disabled search bar
// ---------------------------------------------------------------------------
const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.2 }}
>
<Tooltip title="Coming soon" arrow placement="bottom">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
height: 24,
userSelect: 'none',
cursor: 'default',
}}
>
<SearchIcon sx={{ fontSize: 13, color: c.text.ghost, flexShrink: 0 }} />
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.66rem',
fontWeight: 400,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
Search...
</Typography>
</Box>
</Tooltip>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact pill
// ---------------------------------------------------------------------------
const CompactPill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
text: string;
activeCount: number;
hasApprovals: boolean;
}> = ({ c, text, activeCount, hasApprovals }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
height: 24,
userSelect: 'none',
}}
>
<ActivityIndicator c={c} />
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 500,
color: c.text.tertiary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{text}
</Typography>
{hasApprovals && (
<Box
sx={{
width: 4,
height: 4,
borderRadius: '50%',
bgcolor: c.accent.primary,
flexShrink: 0,
opacity: 0.8,
}}
/>
)}
</Box>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — single approval with icon + name + approve/deny
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
request: ApprovalRequest;
remainingCount: number;
onApprove: (requestId: string) => void;
onDeny: (requestId: string) => void;
onExpand: () => void;
}> = ({ c, request, remainingCount, onApprove, onDeny, onExpand }) => {
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed);
const icon = parsed.isMcp
? (meta.integration?.icon || null)
: getToolIcon(request.tool_name);
return (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 0.5,
height: 24,
userSelect: 'none',
}}
>
<Box
sx={{
width: 16,
height: 16,
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: c.text.tertiary,
'& svg': { width: 12, height: 12 },
}}
>
{icon}
</Box>
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: c.text.secondary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{parsed.displayName}
</Typography>
{remainingCount > 1 && (
<Typography
sx={{
fontSize: '0.6rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
+{remainingCount - 1}
</Typography>
)}
<Tooltip title="Approve" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onApprove(request.id); }}
sx={{
p: 0,
width: 18,
height: 18,
color: '#fff',
bgcolor: c.status.success,
'&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' },
}}
>
<CheckIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Deny" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onDeny(request.id); }}
sx={{
p: 0,
width: 18,
height: 18,
color: c.status.error,
border: `1px solid ${c.status.error}`,
'&:hover': { bgcolor: `${c.status.error}0a` },
}}
>
<CloseIcon sx={{ fontSize: 11 }} />
</IconButton>
</Tooltip>
<Tooltip title="Show details" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onExpand(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<ExpandMoreIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Expanded card
// ---------------------------------------------------------------------------
const ExpandedCard: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
groups: SessionApprovalGroup[];
totalApprovals: number;
activeAgents: TrackedAgent[];
finishedAgents: TrackedAgent[];
hasApprovals: boolean;
hasAgents: boolean;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
onStopAgent: (id: string) => void;
onDismissAgent: (id: string) => void;
onNavigateToDashboard: (dashboardId: string, agentId: string) => void;
onClearAllFinished: () => void;
onCollapse: () => void;
}> = ({
c, groups, totalApprovals,
activeAgents, finishedAgents, hasApprovals, hasAgents,
onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse,
}) => {
const [completedExpanded, setCompletedExpanded] = useState(false);
const headerTitle = hasApprovals && !hasAgents
? 'Approval Required'
: hasAgents && !hasApprovals
? 'Agents'
: 'Notifications';
const badgeCount = totalApprovals + activeAgents.length;
return (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18 }}
>
{/* Header */}
<Box
onClick={!hasApprovals ? onCollapse : undefined}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
cursor: hasApprovals ? 'default' : 'pointer',
userSelect: 'none',
borderBottom: `0.5px solid ${c.border.subtle}`,
'&:hover': !hasApprovals ? { bgcolor: c.border.subtle } : {},
transition: 'background-color 0.15s',
}}
>
<Typography
sx={{
fontSize: '0.76rem',
fontWeight: 600,
color: c.text.muted,
flex: 1,
}}
>
{headerTitle}
</Typography>
{badgeCount > 0 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
flexShrink: 0,
}}
>
{badgeCount}
</Typography>
)}
{!hasApprovals && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onCollapse(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<CloseIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Box>
{/* Content */}
<Box
sx={{
overflow: 'auto',
maxHeight: 'min(420px, calc(100vh - 100px))',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{hasApprovals && (
<Box sx={{ py: 1 }}>
{hasAgents && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
}}
>
Approvals
</Typography>
)}
{groups.map((group) => (
<Box key={group.sessionId} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
{groups.length > 1 && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: c.text.ghost,
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>
)}
{hasApprovals && hasAgents && (
<Box sx={{ mx: 2, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
{hasAgents && (
<Box sx={{ py: 0.75 }}>
{hasApprovals && (
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
pt: 0.25,
}}
>
Agents
</Typography>
)}
{activeAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
{finishedAgents.length > 0 && (
<>
{activeAgents.length > 0 && (
<Box sx={{ mx: 2, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
<Box
onClick={() => setCompletedExpanded((v) => !v)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 0.5,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { bgcolor: c.border.subtle },
transition: 'background-color 0.15s',
}}
>
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
flex: 1,
}}
>
Completed ({finishedAgents.length})
</Typography>
<Typography
component="span"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onClearAllFinished(); }}
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
cursor: 'pointer',
'&:hover': { color: c.text.secondary },
transition: 'color 0.15s',
}}
>
Clear all
</Typography>
<IconButton size="small" sx={{ p: 0, color: c.text.ghost }}>
{completedExpanded
? <ExpandLessIcon sx={{ fontSize: 14 }} />
: <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Box>
<Collapse in={completedExpanded}>
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</Collapse>
</>
)}
</Box>
)}
</Box>
</motion.div>
);
};
export default DynamicIsland;
@@ -1,459 +0,0 @@
import React, { useMemo, useCallback, useState, useEffect } from 'react';
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,
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';
interface SessionApprovalGroup {
sessionId: string;
sessionName: string;
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) {
result.push({
sessionId,
sessionName: session.name || 'Agent',
approvals: session.pending_approvals,
});
}
}
return result;
}, [sessions]);
const totalApprovals = useMemo(
() => groups.reduce((sum, g) => sum + g.approvals.length, 0),
[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 || activeAgents.length > 0) {
setCollapsed(false);
}
}, [totalApprovals, activeAgents.length]);
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
},
[dispatch],
);
const onDeny = useCallback(
(requestId: string, message?: string) => {
dispatch(handleApproval({ requestId, behavior: 'deny', message }));
},
[dispatch],
);
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
sx={{
position: 'fixed',
top: 16,
right: 16,
zIndex: 9999,
width: collapsed ? 'auto' : 420,
maxWidth: 'calc(100vw - 280px)',
maxHeight: 'calc(100vh - 32px)',
display: 'flex',
flexDirection: 'column',
borderRadius: `${c.radius.xl}px`,
bgcolor: c.bg.surface,
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': {
from: { opacity: 0, transform: 'translateY(-12px) scale(0.97)' },
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
},
}}
>
{/* Header */}
<Box
onClick={() => setCollapsed((v) => !v)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1.25,
bgcolor: headerBg,
borderBottom: collapsed ? 'none' : `1px solid ${headerColor}20`,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { bgcolor: `${headerColor}18` },
transition: 'background-color 0.15s',
}}
>
<NotificationsActiveIcon
sx={{
fontSize: 18,
color: headerColor,
animation: hasApprovals ? 'approvalBell 0.6s ease-in-out' : 'none',
'@keyframes approvalBell': {
'0%': { transform: 'rotate(0)' },
'20%': { transform: 'rotate(12deg)' },
'40%': { transform: 'rotate(-10deg)' },
'60%': { transform: 'rotate(6deg)' },
'80%': { transform: 'rotate(-3deg)' },
'100%': { transform: 'rotate(0)' },
},
}}
/>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: headerColor, flex: 1 }}>
{headerTitle}
</Typography>
{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>
</Box>
{/* Content */}
{!collapsed && (
<Box
sx={{
overflow: 'auto',
maxHeight: 'calc(100vh - 120px)',
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{/* Approvals section */}
{hasApprovals && (
<Box sx={{ py: 1 }}>
{hasAgents && (
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 700,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
px: 2,
pb: 0.5,
}}
>
Approvals
</Typography>
)}
{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>
);
};
export default GlobalApprovalOverlay;
@@ -23,8 +23,6 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AddIcon from '@mui/icons-material/Add';
import SettingsIcon from '@mui/icons-material/Settings';
import ExtensionIcon from '@mui/icons-material/Extension';
import PhoneIcon from '@mui/icons-material/Phone';
import BarChartIcon from '@mui/icons-material/BarChart';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
@@ -33,8 +31,7 @@ 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 TalkModeOverlay from '@/app/components/TalkModeOverlay';
import DynamicIsland from '@/app/components/DynamicIsland';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
@@ -71,7 +68,6 @@ const AppShell: React.FC = () => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [talkModeOpen, setTalkModeOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
try {
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
@@ -233,8 +229,6 @@ const AppShell: React.FC = () => {
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const isChannelsRoute = location.pathname === '/channels';
const isAnalyticsRoute = location.pathname === '/analytics';
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
@@ -302,6 +296,8 @@ const AppShell: React.FC = () => {
borderBottom: `0.5px solid ${c.border.medium}`,
display: 'flex',
alignItems: 'center',
position: 'relative',
overflow: 'visible',
WebkitAppRegion: 'drag',
userSelect: 'none',
pl: '78px',
@@ -354,6 +350,8 @@ const AppShell: React.FC = () => {
</IconButton>
</Tooltip>
<DynamicIsland />
<Box sx={{ flex: 1 }} />
<Box
@@ -369,12 +367,12 @@ const AppShell: React.FC = () => {
component="img"
src="./logo.png"
alt="OpenSwarm"
sx={{ width: 18, height: 18, borderRadius: 0.5, opacity: 0.7 }}
sx={{ width: 16, height: 16, borderRadius: 0.5, opacity: 0.6 }}
/>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
fontSize: '0.72rem',
fontWeight: 500,
letterSpacing: 0.3,
lineHeight: 1,
@@ -845,38 +843,6 @@ const AppShell: React.FC = () => {
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Channels section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => navigate('/channels')}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
bgcolor: isChannelsRoute ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isChannelsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: isChannelsRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<PhoneIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary="Channels"
sx={{
'& .MuiListItemText-primary': {
color: isChannelsRoute ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isChannelsRoute ? 600 : 400,
},
}}
/>
</ListItemButton>
</Box>
{/* Settings */}
<Box
sx={{
@@ -962,11 +928,6 @@ const AppShell: React.FC = () => {
</Box>
<Settings />
<GlobalApprovalOverlay />
<TalkModeOverlay
open={talkModeOpen}
onClose={() => setTalkModeOpen(false)}
/>
<Snackbar
open={showUpdateSnackbar}
@@ -43,7 +43,7 @@ import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput, { ChatInputHandle } from './ChatInput';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const CONTEXT_WINDOWS_DEFAULT: Record<string, number> = {
@@ -211,13 +211,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt }));
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId }));
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' }));
}
}
});
} else {
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id }));
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
.then((action) => {
@@ -241,7 +241,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error';
if (wasActive && isTerminal) {
if (id) dispatch(clearGlowingBrowserCards(id));
if (id) {
dispatch(fadeGlowingBrowserCards(id));
setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800);
}
const nextQueued = messageQueueRef.current.shift();
if (nextQueued) {
@@ -59,14 +59,14 @@ const INTEGRATION_META: Record<string, IntegrationMeta> = {
// MCP tool name parser
// ---------------------------------------------------------------------------
interface ParsedTool {
export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
actionName: string;
displayName: string;
}
function parseMcpToolName(rawName: string): ParsedTool {
export function parseMcpToolName(rawName: string): ParsedTool {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
if (!m) {
return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName };
@@ -93,7 +93,7 @@ interface McpToolMeta {
serverLabel: string;
}
function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
const toolItems = useAppSelector((s) => s.tools.items);
return useMemo(() => {
@@ -185,7 +185,7 @@ interface Props {
onDeny: (requestId: string, message?: string) => void;
}
function getToolIcon(toolName: string) {
export function getToolIcon(toolName: string) {
switch (toolName) {
case 'Bash': return <TerminalIcon sx={{ fontSize: '1rem' }} />;
case 'Read': return <DescriptionIcon sx={{ fontSize: '1rem' }} />;
@@ -185,6 +185,8 @@ interface Props {
onMeasuredHeight?: (sessionId: string, height: number) => void;
snapColumn?: { x: number; width: number };
autoFocusInput?: boolean;
cardZOrder?: number;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
}
const MIN_W = 480;
@@ -201,7 +203,7 @@ const SNAP_THRESHOLD = 60;
const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -464,9 +466,10 @@ const AgentCard: React.FC<Props> = ({
animate={{ opacity: 1, scale: 1, left: activeX, top: activeY }}
exit={exitAnimation}
transition={spawnTransition}
onPointerDownCapture={() => onBringToFront?.(session.id, 'agent')}
style={{
position: 'absolute',
zIndex: isDragging || isResizing ? 999 : expanded ? 100 : 'auto',
zIndex: isDragging || isResizing ? 999999 : cardZOrder,
}}
>
<Box
@@ -539,7 +542,6 @@ const AgentCard: React.FC<Props> = ({
boxShadow: c.shadow.sm,
},
},
zIndex: 50,
}),
...(!isHighlighted && isGlowingRedux && !glowFading && {
animation: 'agent-card-glow-pulse 2s ease-in-out infinite',
@@ -66,6 +66,16 @@ const BrowserAgentOverlay: React.FC<Props> = ({ session, browserWidth, browserHe
const isRunning = session.status === 'running';
const isDone = session.status === 'completed' || session.status === 'error' || session.status === 'stopped';
const prevSessionId = useRef(session.id);
useEffect(() => {
if (session.id !== prevSessionId.current) {
prevSessionId.current = session.id;
setFadeOut(false);
setHidden(false);
setConfirmStop(false);
}
}, [session.id]);
useEffect(() => {
if (isDone) {
fadeTimer.current = setTimeout(() => setFadeOut(true), 2000);
@@ -101,12 +101,15 @@ interface Props {
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
cardZOrder?: number;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
}
const BrowserCard: React.FC<Props> = ({
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -117,14 +120,16 @@ const BrowserCard: React.FC<Props> = ({
const browserAgentSession = useAppSelector((state) => {
const sessions = state.agents.sessions;
return Object.values(sessions).find(
const matches = Object.values(sessions).filter(
(s) => s.browser_id === browserId && s.mode === 'browser-agent'
&& (s.status === 'running' || s.status === 'completed' || s.status === 'error'),
) ?? null;
&& (s.status === 'running' || s.status === 'completed' || s.status === 'error' || s.status === 'stopped'),
);
return matches.find((s) => s.status === 'running') ?? matches[matches.length - 1] ?? null;
});
const activity = useBrowserActivity(browserId);
const agentActive = activity.active;
const agentRunning = browserAgentSession?.status === 'running';
const agentActive = activity.active || agentRunning;
const agentAction = activity.action;
const lastAction = activity.lastAction;
@@ -535,6 +540,7 @@ const BrowserCard: React.FC<Props> = ({
data-select-type="browser-card"
data-select-id={browserId}
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
onPointerDownCapture={() => onBringToFront?.(browserId, 'browser')}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(browserId, 'browser', e.shiftKey);
@@ -552,7 +558,7 @@ const BrowserCard: React.FC<Props> = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: isHighlighted ? 50 : (isDragging || isResizing) ? 100 : (agentActive || showGlow) ? 50 : 1,
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
+156 -21
View File
@@ -34,7 +34,9 @@ import {
removeBrowserCard,
pasteBrowserCard,
placeCard,
setCardPosition,
removeCard,
bringToFront,
setGlowingAgentCard,
clearGlowingAgentCard,
DEFAULT_CARD_W,
@@ -102,6 +104,7 @@ const DashboardInner: React.FC = () => {
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 glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards);
const sessionList = Object.values(sessions);
const canvas = useCanvasControls(zoomSensitivity);
@@ -205,6 +208,10 @@ const DashboardInner: React.FC = () => {
selection.selectCard(id, type, shiftKey);
}, [selection]);
const handleBringToFront = useCallback((id: string, type: CardType) => {
dispatch(bringToFront({ id, type }));
}, [dispatch]);
// ---- Viewport event handlers (compose pan + marquee) ----
const handleViewportMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 1) {
@@ -747,7 +754,18 @@ const DashboardInner: React.FC = () => {
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt }));
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId }));
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId, label: 'Use Browser' }));
if (selectedBrowserIds.length === 1) {
const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]];
if (bc) {
dispatch(setCardPosition({
sessionId: realId,
x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12,
y: bc.y,
}));
}
}
}
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
delete spawnOriginsRef.current[draftId];
@@ -889,6 +907,38 @@ const DashboardInner: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]);
useEffect(() => {
const DRIFT_THRESHOLD = 60;
const sourceToSiblings = new Map<string, string[]>();
for (const [browserId, glow] of Object.entries(glowingBrowserCards)) {
const bc = browserCards[browserId];
if (!bc) continue;
const sourceCard = cards[glow.sourceId];
if (!sourceCard) continue;
const expectedX = sourceCard.x + sourceCard.width + GRID_GAP * 12;
if (Math.abs(bc.x - expectedX) > DRIFT_THRESHOLD) continue;
const list = sourceToSiblings.get(glow.sourceId) ?? [];
list.push(browserId);
sourceToSiblings.set(glow.sourceId, list);
}
for (const siblings of sourceToSiblings.values()) {
if (siblings.length < 2) continue;
siblings.sort((a, b) => browserCards[a].y - browserCards[b].y);
let cursor = browserCards[siblings[0]].y;
for (const id of siblings) {
const bc = browserCards[id];
const dy = cursor - bc.y;
if (Math.abs(dy) > 1) {
dispatch(moveCards({ items: [{ id, type: 'browser' as const }], dx: 0, dy }));
}
cursor += bc.height + GRID_GAP * 2;
}
}
}, [glowingBrowserCards, browserCards, cards, dispatch]);
const TETHER_FADE_MS = 2500;
const tethers = useMemo(() => {
@@ -914,7 +964,7 @@ const DashboardInner: React.FC = () => {
].join(' ');
}
return Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, sourceYRatio, label }]) => {
const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, sourceYRatio, label }]) => {
const src = cards[sourceId];
const dst = cards[copyId];
if (!src || !dst) return null;
@@ -952,8 +1002,97 @@ const DashboardInner: React.FC = () => {
fading,
};
}).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>;
const browserTethers = Object.entries(glowingBrowserCards).map(([browserId, { sourceId, fading, label }]) => {
const src = cards[sourceId];
const dst = browserCards[browserId];
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 === browserId) { 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 dstH = dst.height;
const srcCx = srcX + src.width / 2;
const dstCx = dstX + dst.width / 2;
type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' };
const srcAnchors: Anchor[] = [
{ x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' },
{ x: srcX, y: srcY + srcH * 0.54, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + srcH, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + dstH * 0.54, side: 'left' },
{ x: dstX + dst.width, y: dstY + dstH * 0.54, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + dstH, side: 'bottom' },
];
let bestSrc = srcAnchors[0], bestDst = dstAnchors[0];
let bestDist = Infinity;
for (const sa of srcAnchors) {
for (const da of dstAnchors) {
const d = Math.hypot(sa.x - da.x, sa.y - da.y);
if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; }
}
}
const x1 = bestSrc.x, y1 = bestSrc.y;
const x2 = bestDst.x, y2 = bestDst.y;
const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom')
&& (bestDst.side === 'top' || bestDst.side === 'bottom');
let pathD: string;
if (isVertical) {
const dx = x2 - x1;
const dy = y2 - y1;
const midY = y1 + dy / 2;
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
pathD = [
`M ${x1},${y1}`,
`V ${midY - sy * r}`,
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
`H ${x2 - sx * r}`,
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
`V ${y2}`,
].join(' ');
} else {
pathD = elbowPath(x1, y1, x2, y2);
}
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15;
const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2;
return {
key: `browser-${browserId}`,
path: pathD,
labelX,
labelY,
label: label || '',
fading,
};
}).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>;
return [...agentTethers, ...browserTethers];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, cards, expandedSessionIds, liveDragInfo, measuredHeightsTick]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick]);
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -1098,10 +1237,8 @@ const DashboardInner: React.FC = () => {
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 }}
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={8}
@@ -1110,10 +1247,8 @@ const DashboardInner: React.FC = () => {
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 }}
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={2}
@@ -1123,10 +1258,8 @@ const DashboardInner: React.FC = () => {
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 }}
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={1.5}
@@ -1137,11 +1270,7 @@ const DashboardInner: React.FC = () => {
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 }}
>
<g transform={`translate(${t.labelX},${t.labelY})`}>
<rect
x={-4}
y={-14}
@@ -1164,7 +1293,7 @@ const DashboardInner: React.FC = () => {
>
{t.label}
</text>
</motion.g>
</g>
)}
</g>
))}
@@ -1233,6 +1362,7 @@ const DashboardInner: React.FC = () => {
cardY={card.y}
cardWidth={card.width}
cardHeight={card.height}
cardZOrder={card.zOrder ?? 0}
zoom={canvas.zoom}
spawnFrom={origin}
exitTarget={exitTarget}
@@ -1247,6 +1377,7 @@ const DashboardInner: React.FC = () => {
onMeasuredHeight={handleMeasuredHeight}
snapColumn={snapColumn}
autoFocusInput={autoFocusSessionId === session.id}
onBringToFront={handleBringToFront}
/>
);
})}
@@ -1262,6 +1393,7 @@ const DashboardInner: React.FC = () => {
cardY={vc.y}
cardWidth={vc.width}
cardHeight={vc.height}
cardZOrder={vc.zOrder ?? 0}
zoom={canvas.zoom}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(vc.output_id)}
@@ -1271,6 +1403,7 @@ const DashboardInner: React.FC = () => {
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onBringToFront={handleBringToFront}
/>
);
})}
@@ -1284,6 +1417,7 @@ const DashboardInner: React.FC = () => {
cardY={bc.y}
cardWidth={bc.width}
cardHeight={bc.height}
cardZOrder={bc.zOrder ?? 0}
zoom={canvas.zoom}
cmdHeld={canvas.cmdHeld}
isSelected={selection.isSelected(bc.browser_id)}
@@ -1293,6 +1427,7 @@ const DashboardInner: React.FC = () => {
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onBringToFront={handleBringToFront}
/>
))}
{/* Marquee selection rectangle */}
@@ -54,11 +54,14 @@ interface Props {
onDragStart?: (id: string, type: 'agent' | 'view') => void;
onDragMove?: (dx: number, dy: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
cardZOrder?: number;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
}
const DashboardViewCard: React.FC<Props> = ({
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -259,6 +262,7 @@ const DashboardViewCard: React.FC<Props> = ({
data-select-type="view-card"
data-select-id={output.id}
data-select-meta={JSON.stringify({ name: output.name, description: output.description })}
onPointerDownCapture={() => onBringToFront?.(output.id, 'view')}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(output.id, 'view', e.shiftKey);
@@ -284,7 +288,7 @@ const DashboardViewCard: React.FC<Props> = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: isHighlighted ? 50 : (isDragging || isResizing) ? 100 : 1,
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.2s',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
+71 -8
View File
@@ -322,11 +322,14 @@ export const handleApproval = createAsyncThunk(
message?: string;
updatedInput?: Record<string, any>;
}) => {
await fetch(`${AGENTS_API}/approval`, {
const res = await fetch(`${AGENTS_API}/approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }),
});
if (!res.ok) {
throw new Error(`Approval request failed (${res.status})`);
}
return { requestId, behavior };
}
);
@@ -525,10 +528,23 @@ const agentsSlice = createSlice({
},
updateSession(state, action: PayloadAction<AgentSession>) {
if (state.history[action.payload.id]) return;
if (state.history[action.payload.id]) {
if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') {
delete state.history[action.payload.id];
} else {
return;
}
}
const existing = state.sessions[action.payload.id];
// Preserve local pending_approvals if the server payload has none but
// the frontend has some (avoids race where backend clears approvals
// before the frontend processes the removal).
const mergedApprovals = existing?.pending_approvals?.length && !action.payload.pending_approvals?.length
? existing.pending_approvals
: action.payload.pending_approvals ?? [];
state.sessions[action.payload.id] = {
...action.payload,
pending_approvals: mergedApprovals,
streamingMessage: existing?.streamingMessage ?? action.payload.streamingMessage ?? null,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
};
@@ -685,7 +701,7 @@ const agentsSlice = createSlice({
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];
s.status = 'stopped';
}
}
}
@@ -723,6 +739,17 @@ const agentsSlice = createSlice({
(id) => id !== action.payload,
);
},
dismissAllFinishedNotifications(state) {
const finishedStatuses = new Set(['completed', 'error', 'stopped']);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => {
const session = state.sessions[id];
if (session) return !finishedStatuses.has(session.status);
const hist = state.history[id];
if (hist) return !finishedStatuses.has(hist.status);
return true;
});
},
},
extraReducers: (builder) => {
builder
@@ -731,19 +758,35 @@ const agentsSlice = createSlice({
})
.addCase(fetchSessions.fulfilled, (state, action) => {
state.loading = false;
const sessions: Record<string, AgentSession> = {};
const fetchedIds = new Set(action.payload.map((s) => s.id));
const activeStatuses = new Set(['running', 'waiting_approval']);
// Remove stale sessions that belong to this dashboard fetch but
// are no longer returned by the server — keep sessions from other
// dashboards, drafts, tracked notifications, and active sessions.
for (const [id, existing] of Object.entries(state.sessions)) {
if (existing.status === 'draft') sessions[id] = existing;
if (fetchedIds.has(id)) continue;
if (existing.status === 'draft') continue;
if (state.trackedNotificationIds.includes(id)) continue;
if (activeStatuses.has(existing.status)) continue;
delete state.sessions[id];
}
// Merge fetched sessions, preserving local-only fields
for (const s of action.payload) {
const existing = state.sessions[s.id];
sessions[s.id] = {
state.sessions[s.id] = {
...s,
pending_approvals: existing?.pending_approvals?.length
? existing.pending_approvals
: s.pending_approvals ?? [],
streamingMessage: existing?.streamingMessage ?? s.streamingMessage ?? null,
tool_group_meta: s.tool_group_meta ?? {},
tool_group_meta: { ...existing?.tool_group_meta, ...s.tool_group_meta },
};
if (activeStatuses.has(s.status) && !state.trackedNotificationIds.includes(s.id)) {
state.trackedNotificationIds.push(s.id);
}
}
state.sessions = sessions;
})
.addCase(fetchSessions.rejected, (state) => {
state.loading = false;
@@ -795,6 +838,18 @@ const agentsSlice = createSlice({
session.system_prompt = action.payload.systemPrompt;
}
})
.addCase(sendMessage.pending, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session) {
session.status = 'running';
}
})
.addCase(editMessage.pending, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session) {
session.status = 'running';
}
})
.addCase(stopAgent.fulfilled, (state, action) => {
const session = state.sessions[action.payload];
if (session) {
@@ -810,6 +865,11 @@ const agentsSlice = createSlice({
);
}
})
.addCase(handleApproval.rejected, (_state, action) => {
// Approval stays in state so the user can retry.
// The request was never delivered to the backend.
console.error('Approval request failed:', action.error.message);
})
.addCase(switchBranch.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -841,6 +901,7 @@ const agentsSlice = createSlice({
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(closeSession.rejected, (state, action) => {
const sessionId = action.meta.arg.sessionId;
@@ -863,6 +924,7 @@ const agentsSlice = createSlice({
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(deleteSession.fulfilled, (state, action) => {
const sessionId = action.payload;
@@ -962,6 +1024,7 @@ export const {
clearHistorySearch,
trackAgentNotification,
dismissAgentNotification,
dismissAllFinishedNotifications,
} = agentsSlice.actions;
export default agentsSlice.reducer;
@@ -21,6 +21,7 @@ export interface CardPosition {
y: number;
width: number;
height: number;
zOrder: number;
}
export interface ViewCardPosition {
@@ -29,6 +30,7 @@ export interface ViewCardPosition {
y: number;
width: number;
height: number;
zOrder: number;
}
export interface BrowserTab {
@@ -47,6 +49,7 @@ export interface BrowserCardPosition {
y: number;
width: number;
height: number;
zOrder: number;
}
export interface DashboardLayoutState {
@@ -54,9 +57,10 @@ export interface DashboardLayoutState {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, string>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
persistedExpandedSessionIds: string[];
nextZOrder: number;
loading: boolean;
initialized: boolean;
}
@@ -69,6 +73,7 @@ const initialState: DashboardLayoutState = {
glowingBrowserCards: {},
glowingAgentCards: {},
persistedExpandedSessionIds: [],
nextZOrder: 1,
loading: false,
initialized: false,
};
@@ -223,7 +228,25 @@ const dashboardLayoutSlice = createSlice({
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 };
state.cards[sessionId] = { session_id: sessionId, x, y, width, height, zOrder: state.nextZOrder++ };
},
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' }>,
) {
const { id, type } = action.payload;
const z = state.nextZOrder++;
if (type === 'agent') {
const card = state.cards[id];
if (card) card.zOrder = z;
} else if (type === 'view') {
const card = state.viewCards[id];
if (card) card.zOrder = z;
} else {
const card = state.browserCards[id];
if (card) card.zOrder = z;
}
},
removeCard(state, action: PayloadAction<string>) {
@@ -250,7 +273,7 @@ const dashboardLayoutSlice = createSlice({
if (hasDraftCard && !id.startsWith('draft-')) continue;
const savedPos = state.closedCardPositions[id];
if (savedPos) {
state.cards[id] = { ...savedPos, session_id: id };
state.cards[id] = { ...savedPos, session_id: id, zOrder: savedPos.zOrder || state.nextZOrder++ };
delete state.closedCardPositions[id];
} else {
const rects = collectOccupiedRects(state, expandedSessionIds);
@@ -261,6 +284,7 @@ const dashboardLayoutSlice = createSlice({
y: pos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
zOrder: state.nextZOrder++,
};
}
}
@@ -334,6 +358,7 @@ const dashboardLayoutSlice = createSlice({
y: posY,
width: width || DEFAULT_VIEW_CARD_W,
height: height || DEFAULT_VIEW_CARD_H,
zOrder: state.nextZOrder++,
};
},
@@ -376,6 +401,7 @@ const dashboardLayoutSlice = createSlice({
y: pos.y,
width: DEFAULT_BROWSER_CARD_W,
height: DEFAULT_BROWSER_CARD_H,
zOrder: state.nextZOrder++,
};
},
@@ -386,6 +412,7 @@ const dashboardLayoutSlice = createSlice({
...card,
width: card.width || DEFAULT_BROWSER_CARD_W,
height: card.height || DEFAULT_BROWSER_CARD_H,
zOrder: card.zOrder || state.nextZOrder++,
};
},
@@ -449,6 +476,7 @@ const dashboardLayoutSlice = createSlice({
y: posY,
width: width || DEFAULT_BROWSER_CARD_W,
height: height || DEFAULT_BROWSER_CARD_H,
zOrder: state.nextZOrder++,
};
},
@@ -604,18 +632,25 @@ const dashboardLayoutSlice = createSlice({
setGlowingBrowserCards(
state,
action: PayloadAction<{ browserIds: string[]; sessionId: string }>
action: PayloadAction<{ browserIds: string[]; sessionId: string; label?: string }>
) {
const { browserIds, sessionId } = action.payload;
const { browserIds, sessionId, label } = action.payload;
for (const id of browserIds) {
state.glowingBrowserCards[id] = sessionId;
state.glowingBrowserCards[id] = { sourceId: sessionId, fading: false, label };
}
},
fadeGlowingBrowserCards(state, action: PayloadAction<string>) {
const sessionId = action.payload;
for (const entry of Object.values(state.glowingBrowserCards)) {
if (entry.sourceId === sessionId) entry.fading = true;
}
},
clearGlowingBrowserCards(state, action: PayloadAction<string>) {
const sessionId = action.payload;
for (const [browserId, sid] of Object.entries(state.glowingBrowserCards)) {
if (sid === sessionId) delete state.glowingBrowserCards[browserId];
for (const [browserId, entry] of Object.entries(state.glowingBrowserCards)) {
if (entry.sourceId === sessionId) delete state.glowingBrowserCards[browserId];
}
},
@@ -645,6 +680,7 @@ const dashboardLayoutSlice = createSlice({
state.glowingBrowserCards = {};
state.glowingAgentCards = {};
state.persistedExpandedSessionIds = [];
state.nextZOrder = 1;
state.initialized = false;
},
@@ -661,6 +697,22 @@ const dashboardLayoutSlice = createSlice({
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
// Ensure all cards have a zOrder and compute nextZOrder from persisted data
let maxZ = 0;
for (const c of Object.values(state.cards)) {
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.zOrder;
}
for (const c of Object.values(state.viewCards)) {
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.zOrder;
}
for (const c of Object.values(state.browserCards)) {
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.zOrder;
}
state.nextZOrder = maxZ + 1;
})
.addCase(fetchLayout.rejected, (state) => {
state.loading = false;
@@ -671,7 +723,7 @@ const dashboardLayoutSlice = createSlice({
const card = state.cards[draftId];
if (card) {
delete state.cards[draftId];
state.cards[session.id] = { ...card, session_id: session.id };
state.cards[session.id] = { ...card, session_id: session.id, zOrder: state.nextZOrder++ };
}
});
},
@@ -682,6 +734,7 @@ export const {
placeCard,
setCardSize,
removeCard,
bringToFront,
reconcileSessions,
replaceDraftId,
tidyLayout,
@@ -705,6 +758,7 @@ export const {
reorderBrowserTab,
moveCards,
setGlowingBrowserCards,
fadeGlowingBrowserCards,
clearGlowingBrowserCards,
clearAllGlowingBrowserCards,
setGlowingAgentCard,
+27 -1
View File
@@ -16,7 +16,7 @@ import {
closeSessionFromWs,
trackAgentNotification,
} from '../state/agentsSlice';
import { addBrowserCardFromBackend } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
type WSEvent = {
event: string;
@@ -249,6 +249,32 @@ class WebSocketManager {
case 'dashboard:browser_card_added':
if (data.browser_card) {
store.dispatch(addBrowserCardFromBackend(data.browser_card));
const parentId = data.parent_session_id;
if (parentId) {
const layoutState = store.getState().dashboardLayout;
const parentCard = layoutState.cards[parentId];
if (parentCard) {
const targetX = parentCard.x + parentCard.width + GRID_GAP * 12;
let targetY = parentCard.y;
const columnCards = Object.values(layoutState.browserCards).filter(
(c) => Math.abs(c.x - targetX) < 50 && c.browser_id !== data.browser_card.browser_id,
);
if (columnCards.length > 0) {
const lowestBottom = Math.max(...columnCards.map((c) => c.y + c.height));
targetY = lowestBottom + GRID_GAP;
}
store.dispatch(setBrowserCardPosition({
browserId: data.browser_card.browser_id,
x: targetX,
y: targetY,
}));
store.dispatch(setGlowingBrowserCards({
browserIds: [data.browser_card.browser_id],
sessionId: parentId,
label: 'Use Browser',
}));
}
}
}
break;
}
File diff suppressed because one or more lines are too long