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.responses import StreamingResponse
from typing import Dict, List, Any, Optional
import asyncio
from datetime import datetime
import json
from middleware.auth_middleware import get_current_user
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.agent_activity_service import AgentActivityService
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
logger = get_service_logger(__name__)
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")
async def get_agent_team_endpoint(
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))
@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")
async def decide_agent_approval_endpoint(
approval_id: int,

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