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

@@ -4,9 +4,11 @@ Provides REST API access to agent orchestration functionality
""" """
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from fastapi.responses import StreamingResponse
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
import asyncio import asyncio
from datetime import datetime from datetime import datetime
import json
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from utils.logger_utils import get_service_logger from utils.logger_utils import get_service_logger
@@ -19,13 +21,119 @@ from services.intelligence.agents.performance_monitor import PerformanceMetric,
from services.database import get_db from services.database import get_db
from services.agent_activity_service import AgentActivityService from services.agent_activity_service import AgentActivityService
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.agent_activity_models import AgentProfile from models.agent_activity_models import AgentProfile, AgentRun, AgentEvent, AgentAlert, AgentApprovalRequest
from services.intelligence.agents.team_catalog import AGENT_TEAM_CATALOG, get_agent_catalog_entry from services.intelligence.agents.team_catalog import AGENT_TEAM_CATALOG, get_agent_catalog_entry
logger = get_service_logger(__name__) logger = get_service_logger(__name__)
router = APIRouter(prefix="/api/agents", tags=["Autonomous Agents"]) router = APIRouter(prefix="/api/agents", tags=["Autonomous Agents"])
def _serialize_run(run: AgentRun) -> Dict[str, Any]:
return {
"id": run.id,
"user_id": run.user_id,
"agent_type": run.agent_type,
"status": run.status,
"success": run.success,
"error_message": run.error_message,
"result_summary": run.result_summary,
"mlflow_run_id": run.mlflow_run_id,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
}
def _serialize_event(event: AgentEvent) -> Dict[str, Any]:
return {
"id": event.id,
"run_id": event.run_id,
"agent_type": event.agent_type,
"event_type": event.event_type,
"severity": event.severity,
"message": event.message,
"payload": event.payload,
"created_at": event.created_at.isoformat() if event.created_at else None,
}
def _serialize_alert(alert: AgentAlert) -> Dict[str, Any]:
return {
"id": alert.id,
"source": alert.source,
"type": alert.alert_type,
"severity": alert.severity,
"title": alert.title,
"message": alert.message,
"cta_path": alert.cta_path,
"payload": alert.payload,
"created_at": alert.created_at.isoformat() if alert.created_at else None,
"read_at": alert.read_at.isoformat() if alert.read_at else None,
}
def _serialize_approval(approval: AgentApprovalRequest) -> Dict[str, Any]:
return {
"id": approval.id,
"status": approval.status,
"decision": approval.decision,
"action_id": approval.action_id,
"action_type": approval.action_type,
"agent_type": approval.agent_type,
"target_resource": approval.target_resource,
"risk_level": approval.risk_level,
"payload": approval.payload,
"created_at": approval.created_at.isoformat() if approval.created_at else None,
"decided_at": approval.decided_at.isoformat() if approval.decided_at else None,
}
def _build_huddle_snapshot(
db: Session,
user_id: str,
since_run_id: int = 0,
since_event_id: int = 0,
since_alert_id: int = 0,
since_approval_id: int = 0,
limit: int = 50,
) -> Dict[str, Any]:
runs_query = db.query(AgentRun).filter(AgentRun.user_id == user_id)
events_query = db.query(AgentEvent).filter(AgentEvent.user_id == user_id)
alerts_query = db.query(AgentAlert).filter(AgentAlert.user_id == user_id)
approvals_query = db.query(AgentApprovalRequest).filter(AgentApprovalRequest.user_id == user_id)
if since_run_id > 0:
runs_query = runs_query.filter(AgentRun.id > since_run_id)
if since_event_id > 0:
events_query = events_query.filter(AgentEvent.id > since_event_id)
if since_alert_id > 0:
alerts_query = alerts_query.filter(AgentAlert.id > since_alert_id)
if since_approval_id > 0:
approvals_query = approvals_query.filter(AgentApprovalRequest.id > since_approval_id)
runs = runs_query.order_by(AgentRun.id.desc()).limit(limit).all()
events = events_query.order_by(AgentEvent.id.desc()).limit(limit * 2).all()
alerts = alerts_query.order_by(AgentAlert.id.desc()).limit(limit).all()
approvals = approvals_query.order_by(AgentApprovalRequest.id.desc()).limit(limit).all()
runs_sorted = list(reversed(runs))
events_sorted = list(reversed(events))
alerts_sorted = list(reversed(alerts))
approvals_sorted = list(reversed(approvals))
return {
"runs": [_serialize_run(r) for r in runs_sorted],
"events": [_serialize_event(e) for e in events_sorted],
"alerts": [_serialize_alert(a) for a in alerts_sorted],
"approvals": [_serialize_approval(a) for a in approvals_sorted],
"cursor": {
"run_id": max([since_run_id] + [r.id for r in runs_sorted]),
"event_id": max([since_event_id] + [e.id for e in events_sorted]),
"alert_id": max([since_alert_id] + [a.id for a in alerts_sorted]),
"approval_id": max([since_approval_id] + [a.id for a in approvals_sorted]),
},
}
@router.get("/team") @router.get("/team")
async def get_agent_team_endpoint( async def get_agent_team_endpoint(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -627,6 +735,142 @@ async def get_agent_approvals_endpoint(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/huddle/feed")
async def get_agent_huddle_feed_endpoint(
since_run_id: int = 0,
since_event_id: int = 0,
since_alert_id: int = 0,
since_approval_id: int = 0,
limit: int = 50,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
try:
user_id = str(current_user.get("id"))
payload = _build_huddle_snapshot(
db=db,
user_id=user_id,
since_run_id=max(0, int(since_run_id)),
since_event_id=max(0, int(since_event_id)),
since_alert_id=max(0, int(since_alert_id)),
since_approval_id=max(0, int(since_approval_id)),
limit=max(1, min(int(limit), 200)),
)
return {
"success": True,
"data": payload,
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
}
except Exception as e:
logger.error(f"Error getting huddle feed for user {current_user.get('id')}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/huddle/stream")
async def stream_agent_huddle_endpoint(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_id = str(current_user.get("id"))
async def event_generator():
cursor = {"run_id": 0, "event_id": 0, "alert_id": 0, "approval_id": 0}
run_signatures: Dict[int, str] = {}
initial_snapshot = _build_huddle_snapshot(db=db, user_id=user_id, limit=50)
cursor.update(initial_snapshot.get("cursor") or {})
for run in initial_snapshot.get("runs", []):
run_signatures[int(run.get("id") or 0)] = json.dumps(
{
"status": run.get("status"),
"success": run.get("success"),
"finished_at": run.get("finished_at"),
"error_message": run.get("error_message"),
},
sort_keys=True,
)
yield f"event: snapshot\ndata: {json.dumps(initial_snapshot)}\n\n"
while True:
try:
delta = _build_huddle_snapshot(
db=db,
user_id=user_id,
since_run_id=int(cursor.get("run_id", 0)),
since_event_id=int(cursor.get("event_id", 0)),
since_alert_id=int(cursor.get("alert_id", 0)),
since_approval_id=int(cursor.get("approval_id", 0)),
limit=50,
)
recent_runs = (
db.query(AgentRun)
.filter(AgentRun.user_id == user_id)
.order_by(AgentRun.id.desc())
.limit(100)
.all()
)
lifecycle_updates: List[Dict[str, Any]] = []
for run in recent_runs:
signature = json.dumps(
{
"status": run.status,
"success": run.success,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"error_message": run.error_message,
},
sort_keys=True,
)
previous = run_signatures.get(run.id)
if previous != signature:
lifecycle_updates.append(_serialize_run(run))
run_signatures[run.id] = signature
if len(run_signatures) > 300:
keep_ids = {r.id for r in recent_runs}
run_signatures = {k: v for k, v in run_signatures.items() if k in keep_ids}
has_changes = bool(
delta.get("events")
or delta.get("alerts")
or delta.get("approvals")
or lifecycle_updates
)
if has_changes:
if delta.get("cursor"):
cursor.update(delta["cursor"])
event_payload = {
"runs": lifecycle_updates,
"events": delta.get("events", []),
"alerts": delta.get("alerts", []),
"approvals": delta.get("approvals", []),
"cursor": cursor,
"ts": datetime.utcnow().isoformat(),
}
yield f"event: delta\ndata: {json.dumps(event_payload)}\n\n"
else:
yield f"event: heartbeat\ndata: {json.dumps({'ts': datetime.utcnow().isoformat()})}\n\n"
await asyncio.sleep(2.5)
except asyncio.CancelledError:
break
except Exception as stream_error:
logger.warning(f"Huddle stream loop error for user {user_id}: {stream_error}")
error_payload = {"message": "stream_error", "ts": datetime.utcnow().isoformat()}
yield f"event: error\ndata: {json.dumps(error_payload)}\n\n"
await asyncio.sleep(3)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.post("/approvals/{approval_id}/decision") @router.post("/approvals/{approval_id}/decision")
async def decide_agent_approval_endpoint( async def decide_agent_approval_endpoint(
approval_id: int, approval_id: int,

View File

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

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Box, Box,
Paper, Paper,
@@ -12,8 +11,7 @@ import {
ListItemText, ListItemText,
Divider, Divider,
IconButton, IconButton,
Tooltip, Tooltip
CircularProgress
} from '@mui/material'; } from '@mui/material';
import { import {
Psychology as StrategyIcon, Psychology as StrategyIcon,
@@ -21,239 +19,61 @@ import {
Search as SeoIcon, Search as SeoIcon,
Campaign as SocialIcon, Campaign as SocialIcon,
CompareArrows as CompetitorIcon, CompareArrows as CompetitorIcon,
SmartToy as AgentIcon, Refresh as RefreshIcon
Refresh as RefreshIcon,
WarningAmber as WarningIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { apiClient } from '../../../api/client'; import { Link as RouterLink } from 'react-router-dom';
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
interface AgentCard { const ICON_BY_AGENT: Record<string, React.ElementType> = {
id: string; strategy: StrategyIcon,
name: string; content: ContentIcon,
role: string; seo: SeoIcon,
status: 'active' | 'thinking' | 'idle' | 'offline'; social: SocialIcon,
current_activity: string; competitor: CompetitorIcon,
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 TeamHuddleWidget: React.FC = () => { const TeamHuddleWidget: React.FC = () => {
const navigate = useNavigate(); const { runs, connectionMode, lastHeartbeatAt } = useAgentHuddleFeed();
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 fetchTeamData = React.useCallback(async (isRefresh = false) => { // Create rows for the widget from the feed runs
if (isRefresh) { // Note: events are not directly used in the simple widget view, but available if needed
setRefreshing(true); const rows = React.useMemo(() => {
} else { return runs.slice(0, 5).map((run) => {
setLoading(true); const agentType = String(run.agent_type || 'strategy');
} // Simple heuristic for icon mapping
let IconComponent = StrategyIcon;
setError(null); for (const key in ICON_BY_AGENT) {
if (agentType.toLowerCase().includes(key)) {
try { IconComponent = ICON_BY_AGENT[key];
const [statusResp, runsResp, alertsResp] = await Promise.allSettled([ break;
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');
} }
const statusPayload = statusResp.value?.data?.data?.agents || statusResp.value?.data?.agents || []; const status = run.status === 'running' ? 'thinking' : run.success === false ? 'offline' : 'active';
const runsPayload = runsResp.status === 'fulfilled' return {
? (runsResp.value?.data?.data?.runs || runsResp.value?.data?.runs || []) id: run.id,
: []; name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
const alertsPayload = alertsResp.status === 'fulfilled' role: run.status || 'active',
? (alertsResp.value?.data?.data?.alerts || alertsResp.value?.data?.alerts || []) status,
: []; current_activity: run.result_summary || run.error_message || 'Awaiting next update',
icon: IconComponent,
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); }, [runs]);
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]);
return ( return (
<Paper <Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
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" justifyContent="space-between" alignItems="center" mb={2}>
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6" fontWeight={700} color="text.primary"> <Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</Typography>
Team Huddle <Chip label={connectionMode === 'sse' ? 'Live' : connectionMode === 'polling' ? 'Polling' : 'Connecting'} size="small" color={connectionMode === 'sse' ? 'success' : 'warning'} sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
</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>
</Box> </Box>
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}>
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton>
</Tooltip>
</Box> </Box>
{loading ? ( {rows.length === 0 ? (
<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 ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}> <Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
<Typography variant="body2" color="text.secondary" textAlign="center"> <Typography variant="body2" color="text.secondary" textAlign="center">
No active agent activity right now. No active agent activity right now.
@@ -261,76 +81,16 @@ const TeamHuddleWidget: React.FC = () => {
</Box> </Box>
) : ( ) : (
<List disablePadding> <List disablePadding>
{agents.map((agent, index) => ( {rows.map((agent, index) => (
<React.Fragment key={agent.id}> <React.Fragment key={agent.id}>
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />} {index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
<ListItem <ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}>
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>
}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar <Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar>
sx={{
bgcolor: `${agent.color}15`,
color: agent.color,
width: 40,
height: 40
}}
>
<agent.icon fontSize="small" />
</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={ 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>}
<Box display="flex" alignItems="center" gap={1}> 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>}
<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> </ListItem>
</React.Fragment> </React.Fragment>
@@ -339,21 +99,7 @@ const TeamHuddleWidget: React.FC = () => {
)} )}
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center"> <Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
<Typography <Typography component={RouterLink} to="/team-activity" variant="caption" color="primary" sx={{ fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
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' }
}}
>
View Full Team Activity View Full Team Activity
</Typography> </Typography>
</Box> </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'; import React from 'react';
<<<<<<< HEAD
import { import {
Box, Box,
Chip, Chip,
@@ -107,3 +108,70 @@ export default function TeamActivityPage() {
</Box> </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