[Haik]: (fixed auto spawn glitch on reload) (also swapped notification ui to be a floating island)

This commit is contained in:
haikdc
2026-03-20 20:00:44 -07:00
parent 03c1cb1c91
commit a8aff60bca
4 changed files with 737 additions and 491 deletions
@@ -0,0 +1,732 @@
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 StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
import CloseIcon from '@mui/icons-material/Close';
import { motion, AnimatePresence } from 'framer-motion';
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';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IslandState = 'idle' | 'compact' | '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(() => {
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 hasApprovals = totalApprovals > 0;
const hasAgents = trackedAgents.length > 0;
// ---- Island state machine ----
const islandState: IslandState = useMemo(() => {
if (hasApprovals) return 'expanded';
if (userExpanded && hasAgents) return 'expanded';
if (hasAgents) return 'compact';
return 'idle';
}, [hasApprovals, 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)) {
if (!hasApprovals) setUserExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [islandState, hasApprovals]);
// ---- 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 handleIslandClick = useCallback(() => {
if (islandState === 'compact') {
setUserExpanded(true);
} else if (islandState === 'expanded' && !hasApprovals) {
setUserExpanded(false);
}
}, [islandState, hasApprovals]);
// ---- Styling — uses the same neutral palette as the rest of the UI ----
const islandWidth = islandState === 'idle'
? 126
: islandState === 'compact'
? 220
: 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]);
// ---- Render ----
return (
<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' ? handleIslandClick : undefined}
>
<motion.div
layout
transition={SPRING_LAYOUT}
style={{
background: c.bg.secondary,
border: `0.5px solid ${c.border.medium}`,
borderRadius: islandBorderRadius,
boxShadow: shadow,
overflow: 'hidden',
}}
>
<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 === '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}
onCollapse={() => setUserExpanded(false)}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Idle pill
// ---------------------------------------------------------------------------
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 }}
>
<motion.div
animate={{ scale: [1, 1.006, 1] }}
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.6,
px: 1.5,
height: 24,
userSelect: 'none',
}}
>
<Box
component="img"
src="./logo.png"
alt="OpenSwarm"
sx={{ width: 13, height: 13, borderRadius: 0.5, opacity: 0.6 }}
/>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.68rem',
fontWeight: 500,
letterSpacing: 0.3,
lineHeight: 1,
}}
>
OpenSwarm
</Typography>
</Box>
</motion.div>
</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
component="img"
src="./logo.png"
alt=""
sx={{ width: 11, height: 11, borderRadius: 0.25, opacity: 0.45, flexShrink: 0 }}
/>
</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;
onCollapse: () => void;
}> = ({
c, groups, totalApprovals,
activeAgents, finishedAgents, hasApprovals, hasAgents,
onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onCollapse,
}) => {
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: '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.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</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;
@@ -31,7 +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 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';
@@ -296,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',
@@ -348,35 +350,7 @@ const AppShell: React.FC = () => {
</IconButton>
</Tooltip>
<Box sx={{ flex: 1 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pr: 1.5,
WebkitAppRegion: 'no-drag',
}}
>
<Box
component="img"
src="./logo.png"
alt="OpenSwarm"
sx={{ width: 18, height: 18, borderRadius: 0.5, opacity: 0.7 }}
/>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
fontWeight: 500,
letterSpacing: 0.3,
lineHeight: 1,
}}
>
OpenSwarm
</Typography>
</Box>
<DynamicIsland />
</Box>
{showUpdateBanner && (
@@ -924,7 +898,6 @@ const AppShell: React.FC = () => {
</Box>
<Settings />
<GlobalApprovalOverlay />
<Snackbar
open={showUpdateSnackbar}
File diff suppressed because one or more lines are too long