Feat: Add SSE-powered Team Huddle feed and Activity page

This commit is contained in:
ajaysi
2026-03-03 17:40:40 +05:30
5 changed files with 529 additions and 303 deletions

View File

@@ -49,6 +49,7 @@ import IntentResearchTest from './pages/IntentResearchTest';
import SchedulerDashboard from './pages/SchedulerDashboard';
import BillingPage from './pages/BillingPage';
import ApprovalsPage from './pages/ApprovalsPage';
import TeamActivityPage from './pages/TeamActivityPage';
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
import TeamActivityPage from './pages/TeamActivityPage';
import ProtectedRoute from './components/shared/ProtectedRoute';

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Paper,
@@ -12,8 +11,7 @@ import {
ListItemText,
Divider,
IconButton,
Tooltip,
CircularProgress
Tooltip
} from '@mui/material';
import {
Psychology as StrategyIcon,
@@ -21,239 +19,61 @@ import {
Search as SeoIcon,
Campaign as SocialIcon,
CompareArrows as CompetitorIcon,
SmartToy as AgentIcon,
Refresh as RefreshIcon,
WarningAmber as WarningIcon
Refresh as RefreshIcon
} from '@mui/icons-material';
import { apiClient } from '../../../api/client';
import { Link as RouterLink } from 'react-router-dom';
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
interface AgentCard {
id: string;
name: string;
role: string;
status: 'active' | 'thinking' | 'idle' | 'offline';
current_activity: string;
icon: React.ElementType;
color: string;
}
interface AgentMeta {
name: string;
role: string;
icon: React.ElementType;
color: string;
}
const AGENT_META: Record<string, AgentMeta> = {
strategy: {
name: 'Strategy Architect',
role: 'Team Lead',
icon: StrategyIcon,
color: '#6366f1'
},
content: {
name: 'Content Strategist',
role: 'Creative',
icon: ContentIcon,
color: '#10b981'
},
seo: {
name: 'SEO Specialist',
role: 'Technical',
icon: SeoIcon,
color: '#f59e0b'
},
social: {
name: 'Social Manager',
role: 'Engagement',
icon: SocialIcon,
color: '#ec4899'
},
competitor: {
name: 'Competitor Analyst',
role: 'Intelligence',
icon: CompetitorIcon,
color: '#ef4444'
}
};
const normalizeAgentKey = (rawKey: string): string => {
const key = (rawKey || '').toLowerCase();
if (key.includes('strategy')) return 'strategy';
if (key.includes('content')) return 'content';
if (key.includes('seo') || key.includes('search')) return 'seo';
if (key.includes('social')) return 'social';
if (key.includes('competitor') || key.includes('competition')) return 'competitor';
return key;
};
const normalizeStatus = (status: string): AgentCard['status'] => {
const value = (status || '').toLowerCase();
if (value === 'active' || value === 'thinking' || value === 'idle' || value === 'offline') {
return value;
}
if (value === 'running' || value === 'busy') return 'active';
if (value === 'processing' || value === 'analyzing') return 'thinking';
if (value === 'inactive' || value === 'stopped') return 'offline';
return 'idle';
const ICON_BY_AGENT: Record<string, React.ElementType> = {
strategy: StrategyIcon,
content: ContentIcon,
seo: SeoIcon,
social: SocialIcon,
competitor: CompetitorIcon,
};
const TeamHuddleWidget: React.FC = () => {
const navigate = useNavigate();
const [agents, setAgents] = React.useState<AgentCard[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [refreshing, setRefreshing] = React.useState(false);
const [alertCount, setAlertCount] = React.useState(0);
const { runs, connectionMode, lastHeartbeatAt } = useAgentHuddleFeed();
const fetchTeamData = React.useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const [statusResp, runsResp, alertsResp] = await Promise.allSettled([
apiClient.get('/api/agents/status'),
apiClient.get('/api/agents/runs', { params: { limit: 20 } }),
apiClient.get('/api/agents/alerts', { params: { unread_only: true, limit: 20 } })
]);
if (statusResp.status !== 'fulfilled') {
throw new Error('Unable to load team status');
// Create rows for the widget from the feed runs
// Note: events are not directly used in the simple widget view, but available if needed
const rows = React.useMemo(() => {
return runs.slice(0, 5).map((run) => {
const agentType = String(run.agent_type || 'strategy');
// Simple heuristic for icon mapping
let IconComponent = StrategyIcon;
for (const key in ICON_BY_AGENT) {
if (agentType.toLowerCase().includes(key)) {
IconComponent = ICON_BY_AGENT[key];
break;
}
}
const statusPayload = statusResp.value?.data?.data?.agents || statusResp.value?.data?.agents || [];
const runsPayload = runsResp.status === 'fulfilled'
? (runsResp.value?.data?.data?.runs || runsResp.value?.data?.runs || [])
: [];
const alertsPayload = alertsResp.status === 'fulfilled'
? (alertsResp.value?.data?.data?.alerts || alertsResp.value?.data?.alerts || [])
: [];
const runByAgent = new Map<string, string>();
runsPayload.forEach((run: Record<string, unknown>) => {
const key = normalizeAgentKey((run.agent_key || run.agent || run.agent_id || '') as string);
if (!key || runByAgent.has(key)) return;
runByAgent.set(
key,
(run.summary || run.context || run.activity || run.status_message || 'Working on recent tasks') as string
);
});
const normalizedAgents: AgentCard[] = statusPayload.map((agent: Record<string, unknown>, index: number) => {
const rawKey = (agent.key || agent.agent_key || agent.id || agent.name || `agent-${index}`) as string;
const normalizedKey = normalizeAgentKey(rawKey);
const meta = AGENT_META[normalizedKey] || {
name: (agent.display_name || agent.name || rawKey) as string,
role: 'AI Agent',
icon: AgentIcon,
color: '#64748b'
};
return {
id: String(rawKey),
name: (agent.display_name || meta.name) as string,
role: (agent.role || meta.role) as string,
status: normalizeStatus((agent.status || agent.operational_status || '') as string),
current_activity:
(agent.current_activity ||
runByAgent.get(normalizedKey) ||
agent.last_message ||
'Waiting for next instruction') as string,
icon: meta.icon,
color: meta.color
};
});
setAgents(normalizedAgents);
setAlertCount(Array.isArray(alertsPayload) ? alertsPayload.length : 0);
} catch (err) {
console.error('Failed to load team huddle data:', err);
setError('Could not load team huddle data. Please try again.');
setAgents([]);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
React.useEffect(() => {
fetchTeamData();
}, [fetchTeamData]);
const status = run.status === 'running' ? 'thinking' : run.success === false ? 'offline' : 'active';
return {
id: run.id,
name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
role: run.status || 'active',
status,
current_activity: run.result_summary || run.error_message || 'Awaiting next update',
icon: IconComponent,
};
});
}, [runs]);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 3,
border: '1px solid',
borderColor: 'divider',
height: '100%',
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)'
}}
>
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6" fontWeight={700} color="text.primary">
Team Huddle
</Typography>
<Chip
label="Live"
size="small"
color="success"
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
/>
{alertCount > 0 && (
<Chip
icon={<WarningIcon fontSize="small" />}
label={`${alertCount} alert${alertCount > 1 ? 's' : ''}`}
size="small"
color="warning"
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
/>
)}
</Box>
<Box>
<Tooltip title="Refresh Team Status">
<span>
<IconButton size="small" onClick={() => fetchTeamData(true)} disabled={loading || refreshing}>
{refreshing ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</Typography>
<Chip label={connectionMode === 'sse' ? 'Live' : connectionMode === 'polling' ? 'Polling' : 'Connecting'} size="small" color={connectionMode === 'sse' ? 'success' : 'warning'} sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
</Box>
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}>
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
<CircularProgress size={24} />
</Box>
) : error ? (
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight={180} gap={1}>
<Typography variant="body2" color="error.main" textAlign="center">
{error}
</Typography>
<Typography
variant="caption"
color="primary"
sx={{ fontWeight: 600, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
onClick={() => fetchTeamData(true)}
>
Retry
</Typography>
</Box>
) : agents.length === 0 ? (
{rows.length === 0 ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
<Typography variant="body2" color="text.secondary" textAlign="center">
No active agent activity right now.
@@ -261,76 +81,16 @@ const TeamHuddleWidget: React.FC = () => {
</Box>
) : (
<List disablePadding>
{agents.map((agent, index) => (
{rows.map((agent, index) => (
<React.Fragment key={agent.id}>
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
<ListItem
alignItems="flex-start"
disableGutters
sx={{ py: 0.5 }}
secondaryAction={
<Tooltip title={agent.status}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor:
agent.status === 'active' ? '#22c55e' :
agent.status === 'thinking' ? '#3b82f6' :
agent.status === 'offline' ? '#cbd5e1' :
'#94a3b8',
boxShadow: agent.status === 'active' ? '0 0 0 2px rgba(34, 197, 94, 0.2)' : 'none',
animation: agent.status === 'thinking' ? 'pulse 1.5s infinite' : 'none',
'@keyframes pulse': {
'0%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.6, transform: 'scale(1.2)' },
'100%': { opacity: 1, transform: 'scale(1)' }
}
}}
/>
</Tooltip>
}
>
<ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: `${agent.color}15`,
color: agent.color,
width: 40,
height: 40
}}
>
<agent.icon fontSize="small" />
</Avatar>
<Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle2" fontWeight={600}>
{agent.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>
{agent.role}
</Typography>
</Box>
}
secondary={
<Typography
variant="body2"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontSize: '0.75rem',
mt: 0.25
}}
>
{agent.current_activity}
</Typography>
}
primary={<Box display="flex" alignItems="center" gap={1}><Typography variant="subtitle2" fontWeight={600}>{agent.name}</Typography><Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>{agent.role}</Typography></Box>}
secondary={<Typography variant="body2" color="text.secondary" sx={{ display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden', fontSize: '0.75rem', mt: 0.25 }}>{agent.current_activity}</Typography>}
/>
</ListItem>
</React.Fragment>
@@ -339,21 +99,7 @@ const TeamHuddleWidget: React.FC = () => {
)}
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
<Typography
component="button"
type="button"
variant="caption"
color="primary"
onClick={() => navigate('/team-activity')}
sx={{
border: 0,
bgcolor: 'transparent',
p: 0,
fontWeight: 600,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
}}
>
<Typography component={RouterLink} to="/team-activity" variant="caption" color="primary" sx={{ fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
View Full Team Activity
</Typography>
</Box>

View File

@@ -0,0 +1,167 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiClient, getApiUrl, getAuthTokenGetter } from '../api/client';
export interface AgentRunItem { id: number; agent_type?: string; status?: string; success?: boolean | null; result_summary?: string | null; finished_at?: string | null; }
export interface AgentEventItem { id: number; agent_type?: string; event_type?: string; message?: string | null; created_at?: string | null; }
export interface AgentAlertItem { id: number; title?: string; message?: string; severity?: string; read_at?: string | null; }
export interface AgentApprovalItem { id: number; action_type?: string; status?: string; risk_level?: number; created_at?: string | null; }
interface Cursor { run_id: number; event_id: number; alert_id: number; approval_id: number; }
interface FeedPayload {
runs: AgentRunItem[];
events: AgentEventItem[];
alerts: AgentAlertItem[];
approvals: AgentApprovalItem[];
cursor: Cursor;
}
const DEFAULT_CURSOR: Cursor = { run_id: 0, event_id: 0, alert_id: 0, approval_id: 0 };
const BASE_BACKOFF_MS = 1500;
const MAX_BACKOFF_MS = 20000;
const mergeById = <T extends { id: number }>(prev: T[], incoming: T[], limit = 100): T[] => {
if (!incoming.length) return prev;
const byId = new Map<number, T>();
[...prev, ...incoming].forEach((item) => byId.set(item.id, item));
return Array.from(byId.values()).sort((a, b) => b.id - a.id).slice(0, limit);
};
const parseSseLines = (raw: string): Array<{ event: string; data: string }> => {
return raw
.split('\n\n')
.map((block) => block.trim())
.filter(Boolean)
.map((block) => {
const lines = block.split('\n');
const event = (lines.find((line) => line.startsWith('event:')) || 'event: message').replace('event:', '').trim();
const data = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace('data:', '').trim())
.join('');
return { event, data };
});
};
export const useAgentHuddleFeed = () => {
const [feed, setFeed] = useState<FeedPayload>({ runs: [], events: [], alerts: [], approvals: [], cursor: DEFAULT_CURSOR });
const [connectionMode, setConnectionMode] = useState<'connecting' | 'sse' | 'polling'>('connecting');
const [lastHeartbeatAt, setLastHeartbeatAt] = useState<number | null>(null);
const stopRef = useRef(false);
const reconnectAttemptRef = useRef(0);
const cursorRef = useRef<Cursor>(DEFAULT_CURSOR);
const applyPayload = useCallback((payload: Partial<FeedPayload>, replace = false) => {
setFeed((prev) => ({
runs: replace ? (payload.runs || []) : mergeById(prev.runs, payload.runs || []),
events: replace ? (payload.events || []) : mergeById(prev.events, payload.events || []),
alerts: replace ? (payload.alerts || []) : mergeById(prev.alerts, payload.alerts || []),
approvals: replace ? (payload.approvals || []) : mergeById(prev.approvals, payload.approvals || []),
cursor: {
run_id: payload.cursor?.run_id ?? prev.cursor.run_id,
event_id: payload.cursor?.event_id ?? prev.cursor.event_id,
alert_id: payload.cursor?.alert_id ?? prev.cursor.alert_id,
approval_id: payload.cursor?.approval_id ?? prev.cursor.approval_id,
},
}));
if (payload.cursor) {
cursorRef.current = {
run_id: payload.cursor.run_id ?? cursorRef.current.run_id,
event_id: payload.cursor.event_id ?? cursorRef.current.event_id,
alert_id: payload.cursor.alert_id ?? cursorRef.current.alert_id,
approval_id: payload.cursor.approval_id ?? cursorRef.current.approval_id,
};
}
}, []);
const loadSnapshot = useCallback(async (cursor?: Cursor) => {
const resp = await apiClient.get('/api/agents/huddle/feed', { params: cursor || {} });
const data = resp?.data?.data as FeedPayload;
applyPayload(data, !cursor);
return data;
}, [applyPayload]);
useEffect(() => {
stopRef.current = false;
let pollingTimer: ReturnType<typeof setInterval> | null = null;
const startPolling = () => {
setConnectionMode('polling');
if (pollingTimer) clearInterval(pollingTimer);
pollingTimer = setInterval(async () => {
try {
await loadSnapshot(cursorRef.current);
} catch {
// no-op
}
}, 7000);
};
const connect = async () => {
try {
setConnectionMode('connecting');
await loadSnapshot();
const tokenGetter = getAuthTokenGetter();
const token = tokenGetter ? await tokenGetter() : null;
if (!token) throw new Error('No auth token available for SSE stream');
const response = await fetch(`${getApiUrl()}/api/agents/huddle/stream`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
});
if (!response.ok || !response.body) {
throw new Error(`SSE stream unavailable (${response.status})`);
}
reconnectAttemptRef.current = 0;
setConnectionMode('sse');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (!stopRef.current) {
const { done, value } = await reader.read();
if (done) {
throw new Error('SSE stream ended');
}
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() || '';
for (const packet of parseSseLines(chunks.join('\n\n'))) {
if (!packet.data) continue;
if (packet.event === 'heartbeat') {
setLastHeartbeatAt(Date.now());
continue;
}
const payload = JSON.parse(packet.data);
if (packet.event === 'snapshot') {
applyPayload(payload, true);
}
if (packet.event === 'delta') {
applyPayload(payload, false);
}
}
}
} catch {
reconnectAttemptRef.current += 1;
if (reconnectAttemptRef.current >= 3) {
startPolling();
return;
}
const sleepMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * (2 ** reconnectAttemptRef.current));
await new Promise((resolve) => setTimeout(resolve, sleepMs));
if (!stopRef.current) connect();
}
};
connect();
return () => {
stopRef.current = true;
if (pollingTimer) clearInterval(pollingTimer);
};
}, [applyPayload, loadSnapshot]);
return useMemo(() => ({ ...feed, connectionMode, lastHeartbeatAt }), [feed, connectionMode, lastHeartbeatAt]);
};

View File

@@ -1,4 +1,5 @@
import React from 'react';
<<<<<<< HEAD
import {
Box,
Chip,
@@ -107,3 +108,70 @@ export default function TeamActivityPage() {
</Box>
);
}
=======
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
const TeamActivityPage: React.FC = () => {
const { runs, events, alerts, approvals, connectionMode } = useAgentHuddleFeed();
return (
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Team Activity</Typography>
<Chip label={connectionMode === 'sse' ? 'Live stream' : 'Polling fallback'} color={connectionMode === 'sse' ? 'success' : 'warning'} />
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Run lifecycle updates</Typography>
<List dense>
{runs.slice(0, 20).map((run) => (
<ListItem key={run.id}><ListItemText primary={`${run.agent_type || 'agent'} · ${run.status}`} secondary={run.result_summary || run.finished_at || 'In progress'} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>New events</Typography>
<List dense>
{events.slice(0, 20).map((event) => (
<ListItem key={event.id}><ListItemText primary={`${event.agent_type || 'agent'} · ${event.event_type}`} secondary={event.message || event.created_at} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
<Grid item xs={12}><Divider /></Grid>
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Alert deltas</Typography>
<List dense>
{alerts.slice(0, 20).map((alert) => (
<ListItem key={alert.id}><ListItemText primary={alert.title || 'Alert'} secondary={alert.message} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
<Grid item xs={12} md={6}>
<Card><CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Approval deltas</Typography>
<List dense>
{approvals.slice(0, 20).map((approval) => (
<ListItem key={approval.id}><ListItemText primary={`${approval.action_type || 'Action'} · ${approval.status}`} secondary={approval.created_at} /></ListItem>
))}
</List>
</CardContent></Card>
</Grid>
</Grid>
</Box>
);
};
export default TeamActivityPage;
>>>>>>> pr-368