mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] merge haik/updates-v1 + bump to v1.0.12
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}],
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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 && {
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user