Update frontend components: TeamHuddleWidget, useAgentHuddleFeed, TeamActivityPage
This commit is contained in:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -46,7 +46,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@0no-co/graphql.web": {
|
"node_modules/@0no-co/graphql.web": {
|
||||||
@@ -24057,16 +24057,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "4.9.5",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.2.0"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"proxy": "http://localhost:8000",
|
"proxy": "http://localhost:8000",
|
||||||
"homepage": "/"
|
"homepage": "/"
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import BillingPage from './pages/BillingPage';
|
|||||||
import ApprovalsPage from './pages/ApprovalsPage';
|
import ApprovalsPage from './pages/ApprovalsPage';
|
||||||
import TeamActivityPage from './pages/TeamActivityPage';
|
import TeamActivityPage from './pages/TeamActivityPage';
|
||||||
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
||||||
import TeamActivityPage from './pages/TeamActivityPage';
|
|
||||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||||
import Landing from './components/Landing/Landing';
|
import Landing from './components/Landing/Landing';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Refresh as RefreshIcon,
|
Refresh as RefreshIcon,
|
||||||
ExpandMore as ExpandMoreIcon,
|
ExpandMore as ExpandMoreIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { apiClient } from '../../../api/client';
|
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
|
||||||
|
|
||||||
type EventPayload = {
|
type EventPayload = {
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
@@ -34,68 +34,39 @@ type EventPayload = {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TeamActivityEvent = {
|
|
||||||
id: number;
|
|
||||||
event_type: string;
|
|
||||||
severity: string;
|
|
||||||
message?: string | null;
|
|
||||||
created_at?: string | null;
|
|
||||||
payload?: EventPayload | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AgentRun = {
|
|
||||||
id: number;
|
|
||||||
agent_type: string;
|
|
||||||
status: string;
|
|
||||||
started_at?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeamHuddleWidget: React.FC = () => {
|
const TeamHuddleWidget: React.FC = () => {
|
||||||
const [loading, setLoading] = React.useState(true);
|
const { runs, events, connectionMode } = useAgentHuddleFeed({ detailTier: 'detailed' });
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [timeline, setTimeline] = React.useState<Array<{ run: AgentRun; events: TeamActivityEvent[] }>>([]);
|
|
||||||
|
|
||||||
const loadTimeline = React.useCallback(async () => {
|
// Group events by run_id for the UI
|
||||||
setLoading(true);
|
const timeline = useMemo(() => {
|
||||||
setError(null);
|
// Only take top 5 runs
|
||||||
try {
|
const topRuns = runs.slice(0, 5);
|
||||||
const runsResp = await apiClient.get('/api/agents/runs', { params: { limit: 5 } });
|
return topRuns.map(run => {
|
||||||
const runs: AgentRun[] = runsResp?.data?.data?.runs || [];
|
// Find events for this run
|
||||||
|
const runEvents = events
|
||||||
|
.filter(e => e.run_id === run.id)
|
||||||
|
.map(e => ({
|
||||||
|
...e,
|
||||||
|
payload: (e.payload || {}) as EventPayload
|
||||||
|
}));
|
||||||
|
return { run, events: runEvents };
|
||||||
|
});
|
||||||
|
}, [runs, events]);
|
||||||
|
|
||||||
const eventResponses = await Promise.all(
|
const loading = connectionMode === 'connecting' && runs.length === 0;
|
||||||
runs.slice(0, 3).map(async (run) => {
|
|
||||||
const eventsResp = await apiClient.get(`/api/agents/runs/${run.id}/events`, { params: { limit: 25 } });
|
|
||||||
return {
|
|
||||||
run,
|
|
||||||
events: (eventsResp?.data?.data?.events || []) as TeamActivityEvent[],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeline(eventResponses);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to load team activity');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
loadTimeline();
|
|
||||||
}, [loadTimeline]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%' }}>
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '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}>Team Activity</Typography>
|
<Typography variant="h6" fontWeight={700}>Team Activity</Typography>
|
||||||
<Chip label="Live" size="small" color="success" sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
|
<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>
|
</Box>
|
||||||
<Tooltip title="Refresh Team Activity">
|
|
||||||
<IconButton size="small" onClick={loadTimeline}>
|
|
||||||
<RefreshIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -104,15 +75,11 @@ const TeamHuddleWidget: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && timeline.length === 0 && (
|
||||||
<Typography variant="body2" color="error">{error}</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && timeline.length === 0 && (
|
|
||||||
<Typography variant="body2" color="text.secondary">No team activity yet.</Typography>
|
<Typography variant="body2" color="text.secondary">No team activity yet.</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && timeline.length > 0 && (
|
{!loading && timeline.length > 0 && (
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{timeline.map(({ run, events }, index) => (
|
{timeline.map(({ run, events }, index) => (
|
||||||
<React.Fragment key={run.id}>
|
<React.Fragment key={run.id}>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { apiClient, getApiUrl, getAuthTokenGetter } from '../api/client';
|
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 AgentRunItem { id: number; agent_type?: string; status?: string; success?: boolean | null; result_summary?: string | null; error_message?: string | null; finished_at?: string | null; started_at?: string | null; }
|
||||||
export interface AgentEventItem { id: number; agent_type?: string; event_type?: string; message?: string | null; created_at?: string | null; }
|
export interface AgentEventItem { id: number; run_id?: number; agent_type?: string; event_type?: string; message?: string | null; created_at?: string | null; payload?: any; }
|
||||||
export interface AgentAlertItem { id: number; title?: string; message?: string; severity?: string; read_at?: string | null; }
|
export interface AgentAlertItem { id: number; title?: string; message?: string; severity?: string; read_at?: string | null; payload?: any; }
|
||||||
export interface AgentApprovalItem { id: number; action_type?: string; status?: string; risk_level?: number; created_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 Cursor { run_id: number; event_id: number; alert_id: number; approval_id: number; }
|
||||||
@@ -42,13 +42,14 @@ const parseSseLines = (raw: string): Array<{ event: string; data: string }> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAgentHuddleFeed = () => {
|
export const useAgentHuddleFeed = (options?: { detailTier?: 'summary' | 'detailed' | 'debug' }) => {
|
||||||
const [feed, setFeed] = useState<FeedPayload>({ runs: [], events: [], alerts: [], approvals: [], cursor: DEFAULT_CURSOR });
|
const [feed, setFeed] = useState<FeedPayload>({ runs: [], events: [], alerts: [], approvals: [], cursor: DEFAULT_CURSOR });
|
||||||
const [connectionMode, setConnectionMode] = useState<'connecting' | 'sse' | 'polling'>('connecting');
|
const [connectionMode, setConnectionMode] = useState<'connecting' | 'sse' | 'polling'>('connecting');
|
||||||
const [lastHeartbeatAt, setLastHeartbeatAt] = useState<number | null>(null);
|
const [lastHeartbeatAt, setLastHeartbeatAt] = useState<number | null>(null);
|
||||||
const stopRef = useRef(false);
|
const stopRef = useRef(false);
|
||||||
const reconnectAttemptRef = useRef(0);
|
const reconnectAttemptRef = useRef(0);
|
||||||
const cursorRef = useRef<Cursor>(DEFAULT_CURSOR);
|
const cursorRef = useRef<Cursor>(DEFAULT_CURSOR);
|
||||||
|
const detailTier = options?.detailTier || 'summary';
|
||||||
|
|
||||||
const applyPayload = useCallback((payload: Partial<FeedPayload>, replace = false) => {
|
const applyPayload = useCallback((payload: Partial<FeedPayload>, replace = false) => {
|
||||||
setFeed((prev) => ({
|
setFeed((prev) => ({
|
||||||
@@ -74,11 +75,12 @@ export const useAgentHuddleFeed = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSnapshot = useCallback(async (cursor?: Cursor) => {
|
const loadSnapshot = useCallback(async (cursor?: Cursor) => {
|
||||||
const resp = await apiClient.get('/api/agents/huddle/feed', { params: cursor || {} });
|
const params = { ...(cursor || {}), detail_tier: detailTier };
|
||||||
|
const resp = await apiClient.get('/api/agents/huddle/feed', { params });
|
||||||
const data = resp?.data?.data as FeedPayload;
|
const data = resp?.data?.data as FeedPayload;
|
||||||
applyPayload(data, !cursor);
|
applyPayload(data, !cursor);
|
||||||
return data;
|
return data;
|
||||||
}, [applyPayload]);
|
}, [applyPayload, detailTier]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stopRef.current = false;
|
stopRef.current = false;
|
||||||
@@ -104,7 +106,8 @@ export const useAgentHuddleFeed = () => {
|
|||||||
const token = tokenGetter ? await tokenGetter() : null;
|
const token = tokenGetter ? await tokenGetter() : null;
|
||||||
if (!token) throw new Error('No auth token available for SSE stream');
|
if (!token) throw new Error('No auth token available for SSE stream');
|
||||||
|
|
||||||
const response = await fetch(`${getApiUrl()}/api/agents/huddle/stream`, {
|
const streamUrl = `${getApiUrl()}/api/agents/huddle/stream?detail_tier=${detailTier}`;
|
||||||
|
const response = await fetch(streamUrl, {
|
||||||
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
|
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
<<<<<<< HEAD
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
const activeRuns = [
|
|
||||||
{ id: 'run-9142', title: 'Q4 SEO Pillar Refresh', owner: 'SEO Specialist', progress: '67%' },
|
|
||||||
{ id: 'run-9143', title: 'LinkedIn Carousel Draft', owner: 'Content Strategist', progress: '42%' },
|
|
||||||
{ id: 'run-9144', title: 'Competitor Monitoring Sweep', owner: 'Competitor Analyst', progress: '88%' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const timelineEvents = [
|
|
||||||
{ time: '09:20', detail: 'Strategy Architect approved updated campaign goals.' },
|
|
||||||
{ time: '09:35', detail: 'Social Manager queued 6 posts for review.' },
|
|
||||||
{ time: '10:05', detail: 'SEO Specialist flagged ranking drop on "AI copy tools".' },
|
|
||||||
{ time: '10:24', detail: 'Content Strategist generated new briefing packet.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const alerts = [
|
|
||||||
{ severity: 'high', label: '2 content briefs are blocked on missing references.' },
|
|
||||||
{ severity: 'medium', label: 'SERP volatility increased for 3 tracked keywords.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const approvals = [
|
|
||||||
{ id: 'ap-112', action: 'Publish LinkedIn thread for Campaign Alpha', requestedBy: 'Social Manager' },
|
|
||||||
{ id: 'ap-113', action: 'Approve budget reallocation to ad creatives', requestedBy: 'Strategy Architect' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TeamActivityPage() {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
|
||||||
Team Activity
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
Real-time view of active agent workstreams, timelines, alerts, and pending approvals.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={2.5}>
|
|
||||||
<Paper sx={{ p: 2.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Active Runs</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{activeRuns.map((run) => (
|
|
||||||
<Stack key={run.id} direction="row" justifyContent="space-between" alignItems="center">
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>{run.title}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{run.owner}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip size="small" label={run.progress} color="primary" variant="outlined" />
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Event Timeline</Typography>
|
|
||||||
<List disablePadding>
|
|
||||||
{timelineEvents.map((event, index) => (
|
|
||||||
<React.Fragment key={`${event.time}-${event.detail}`}>
|
|
||||||
{index > 0 && <Divider sx={{ my: 1 }} />}
|
|
||||||
<ListItem disableGutters sx={{ py: 0 }}>
|
|
||||||
<ListItemText
|
|
||||||
primary={<Typography sx={{ fontWeight: 600 }}>{event.detail}</Typography>}
|
|
||||||
secondary={<Typography variant="caption">{event.time}</Typography>}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Alerts</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{alerts.map((alert) => (
|
|
||||||
<Chip
|
|
||||||
key={alert.label}
|
|
||||||
label={alert.label}
|
|
||||||
color={alert.severity === 'high' ? 'error' : 'warning'}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ justifyContent: 'flex-start' }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2.5 }}>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Approvals Requiring Action</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{approvals.map((approval) => (
|
|
||||||
<Box key={approval.id}>
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>{approval.action}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">Requested by {approval.requestedBy}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
=======
|
|
||||||
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
|
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
|
||||||
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
|
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
|
||||||
|
|
||||||
@@ -174,4 +64,3 @@ const TeamActivityPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default TeamActivityPage;
|
export default TeamActivityPage;
|
||||||
>>>>>>> pr-368
|
|
||||||
|
|||||||
Reference in New Issue
Block a user