/** * ActivityFeed Component * * A shared real-time activity feed component used across: * - Main Dashboard * - Project Dashboard * - Activity Feed page * * Features: * - Real-time connection indicator * - Time-based event grouping (Today, Yesterday, This Week, etc.) * - Event type filtering * - Expandable event details * - Approval request handling * - Search functionality */ 'use client'; import { useState, useMemo, useCallback } from 'react'; import { formatDistanceToNow, isToday, isYesterday, isThisWeek } from 'date-fns'; import { Activity, Bot, FileText, Users, CheckCircle2, XCircle, PlayCircle, AlertTriangle, Folder, Workflow, ChevronDown, ChevronRight, Search, Filter, RefreshCw, CircleDot, GitPullRequest, ExternalLink, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { EventType, type ProjectEvent, isAgentEvent, isIssueEvent, isSprintEvent, isApprovalEvent, isWorkflowEvent, isProjectEvent, } from '@/lib/types/events'; import type { ConnectionState } from '@/lib/types/events'; // ============================================================================ // Types // ============================================================================ export interface ActivityFeedProps { /** Events to display */ events: ProjectEvent[]; /** SSE connection state */ connectionState: ConnectionState; /** Whether data is loading */ isLoading?: boolean; /** Callback to trigger reconnection */ onReconnect?: () => void; /** Callback when an approval is approved */ onApprove?: (event: ProjectEvent) => void; /** Callback when an approval is rejected */ onReject?: (event: ProjectEvent) => void; /** Callback when event is clicked */ onEventClick?: (event: ProjectEvent) => void; /** Maximum height for scrolling (default: 'auto') */ maxHeight?: number | string; /** Whether to show the header (default: true) */ showHeader?: boolean; /** Title for the header (default: 'Activity Feed') */ title?: string; /** Whether to enable filtering (default: true) */ enableFiltering?: boolean; /** Whether to enable search (default: true) */ enableSearch?: boolean; /** Show compact view (default: false) */ compact?: boolean; /** Additional CSS classes */ className?: string; } export interface EventTypeFilter { type: EventType | 'all'; label: string; count: number; } interface EventGroup { label: string; events: ProjectEvent[]; } // ============================================================================ // Event Configuration // ============================================================================ const EVENT_TYPE_CONFIG: Record< string, { label: string; icon: typeof Activity; color: string; bgColor: string; } > = { // Agent Events [EventType.AGENT_SPAWNED]: { label: 'Agent Spawned', icon: Bot, color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900', }, [EventType.AGENT_MESSAGE]: { label: 'Agent Message', icon: Bot, color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900', }, [EventType.AGENT_STATUS_CHANGED]: { label: 'Status Changed', icon: Bot, color: 'text-yellow-500', bgColor: 'bg-yellow-100 dark:bg-yellow-900', }, [EventType.AGENT_TERMINATED]: { label: 'Agent Terminated', icon: Bot, color: 'text-gray-500', bgColor: 'bg-gray-100 dark:bg-gray-800', }, // Issue Events [EventType.ISSUE_CREATED]: { label: 'Issue Created', icon: CircleDot, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900', }, [EventType.ISSUE_UPDATED]: { label: 'Issue Updated', icon: FileText, color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900', }, [EventType.ISSUE_ASSIGNED]: { label: 'Issue Assigned', icon: Users, color: 'text-purple-500', bgColor: 'bg-purple-100 dark:bg-purple-900', }, [EventType.ISSUE_CLOSED]: { label: 'Issue Closed', icon: CheckCircle2, color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900', }, // Sprint Events [EventType.SPRINT_STARTED]: { label: 'Sprint Started', icon: PlayCircle, color: 'text-indigo-500', bgColor: 'bg-indigo-100 dark:bg-indigo-900', }, [EventType.SPRINT_COMPLETED]: { label: 'Sprint Completed', icon: CheckCircle2, color: 'text-indigo-500', bgColor: 'bg-indigo-100 dark:bg-indigo-900', }, // Approval Events [EventType.APPROVAL_REQUESTED]: { label: 'Approval Requested', icon: AlertTriangle, color: 'text-orange-500', bgColor: 'bg-orange-100 dark:bg-orange-900', }, [EventType.APPROVAL_GRANTED]: { label: 'Approval Granted', icon: CheckCircle2, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900', }, [EventType.APPROVAL_DENIED]: { label: 'Approval Denied', icon: XCircle, color: 'text-red-500', bgColor: 'bg-red-100 dark:bg-red-900', }, // Project Events [EventType.PROJECT_CREATED]: { label: 'Project Created', icon: Folder, color: 'text-teal-500', bgColor: 'bg-teal-100 dark:bg-teal-900', }, [EventType.PROJECT_UPDATED]: { label: 'Project Updated', icon: Folder, color: 'text-teal-500', bgColor: 'bg-teal-100 dark:bg-teal-900', }, [EventType.PROJECT_ARCHIVED]: { label: 'Project Archived', icon: Folder, color: 'text-gray-500', bgColor: 'bg-gray-100 dark:bg-gray-800', }, // Workflow Events [EventType.WORKFLOW_STARTED]: { label: 'Workflow Started', icon: Workflow, color: 'text-cyan-500', bgColor: 'bg-cyan-100 dark:bg-cyan-900', }, [EventType.WORKFLOW_STEP_COMPLETED]: { label: 'Step Completed', icon: Workflow, color: 'text-cyan-500', bgColor: 'bg-cyan-100 dark:bg-cyan-900', }, [EventType.WORKFLOW_COMPLETED]: { label: 'Workflow Completed', icon: CheckCircle2, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900', }, [EventType.WORKFLOW_FAILED]: { label: 'Workflow Failed', icon: XCircle, color: 'text-red-500', bgColor: 'bg-red-100 dark:bg-red-900', }, }; const FILTER_CATEGORIES = [ { id: 'agent', label: 'Agent Actions', types: [ EventType.AGENT_SPAWNED, EventType.AGENT_MESSAGE, EventType.AGENT_STATUS_CHANGED, EventType.AGENT_TERMINATED, ], }, { id: 'issue', label: 'Issues', types: [ EventType.ISSUE_CREATED, EventType.ISSUE_UPDATED, EventType.ISSUE_ASSIGNED, EventType.ISSUE_CLOSED, ], }, { id: 'sprint', label: 'Sprints', types: [EventType.SPRINT_STARTED, EventType.SPRINT_COMPLETED] }, { id: 'approval', label: 'Approvals', types: [EventType.APPROVAL_REQUESTED, EventType.APPROVAL_GRANTED, EventType.APPROVAL_DENIED], }, { id: 'workflow', label: 'Workflows', types: [ EventType.WORKFLOW_STARTED, EventType.WORKFLOW_STEP_COMPLETED, EventType.WORKFLOW_COMPLETED, EventType.WORKFLOW_FAILED, ], }, { id: 'project', label: 'Projects', types: [EventType.PROJECT_CREATED, EventType.PROJECT_UPDATED, EventType.PROJECT_ARCHIVED], }, ]; // ============================================================================ // Helper Functions // ============================================================================ function getEventConfig(event: ProjectEvent) { const config = EVENT_TYPE_CONFIG[event.type]; if (config) return config; /* istanbul ignore next -- defensive fallbacks for unknown event types */ // Fallback based on event category if (isAgentEvent(event)) { return { icon: Bot, label: event.type, color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900', }; } /* istanbul ignore next -- defensive fallback */ if (isIssueEvent(event)) { return { icon: FileText, label: event.type, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900', }; } /* istanbul ignore next -- defensive fallback */ if (isSprintEvent(event)) { return { icon: PlayCircle, label: event.type, color: 'text-indigo-500', bgColor: 'bg-indigo-100 dark:bg-indigo-900', }; } /* istanbul ignore next -- defensive fallback */ if (isApprovalEvent(event)) { return { icon: AlertTriangle, label: event.type, color: 'text-orange-500', bgColor: 'bg-orange-100 dark:bg-orange-900', }; } /* istanbul ignore next -- defensive fallback */ if (isWorkflowEvent(event)) { return { icon: Workflow, label: event.type, color: 'text-cyan-500', bgColor: 'bg-cyan-100 dark:bg-cyan-900', }; } /* istanbul ignore next -- defensive fallback */ if (isProjectEvent(event)) { return { icon: Folder, label: event.type, color: 'text-teal-500', bgColor: 'bg-teal-100 dark:bg-teal-900', }; } /* istanbul ignore next -- defensive fallback for completely unknown events */ return { icon: Activity, label: event.type, color: 'text-gray-500', bgColor: 'bg-gray-100 dark:bg-gray-800', }; } function getEventSummary(event: ProjectEvent): string { const payload = event.payload as Record; switch (event.type) { /* istanbul ignore next -- AGENT_SPAWNED tested via EventList */ case EventType.AGENT_SPAWNED: return `${payload.agent_name || 'Agent'} spawned as ${payload.role || 'unknown role'}`; case EventType.AGENT_MESSAGE: return String(payload.message || 'No message'); /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.AGENT_STATUS_CHANGED: return `Status: ${payload.previous_status} -> ${payload.new_status}`; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.AGENT_TERMINATED: return payload.termination_reason ? String(payload.termination_reason) : 'Agent terminated'; case EventType.ISSUE_CREATED: return String(payload.title || 'New issue created'); /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.ISSUE_UPDATED: return `Issue ${payload.issue_id || ''} updated`; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.ISSUE_ASSIGNED: return payload.assignee_name ? `Assigned to ${payload.assignee_name}` : 'Issue assignment changed'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.ISSUE_CLOSED: return payload.resolution ? `Closed: ${payload.resolution}` : 'Issue closed'; case EventType.SPRINT_STARTED: return payload.sprint_name ? `Sprint "${payload.sprint_name}" started` : 'Sprint started'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.SPRINT_COMPLETED: return payload.sprint_name ? `Sprint "${payload.sprint_name}" completed` : 'Sprint completed'; case EventType.APPROVAL_REQUESTED: return String(payload.description || 'Approval requested'); /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.APPROVAL_GRANTED: return 'Approval granted'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.APPROVAL_DENIED: return payload.reason ? `Denied: ${payload.reason}` : 'Approval denied'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.WORKFLOW_STARTED: return payload.workflow_type ? `${payload.workflow_type} workflow started` : 'Workflow started'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.WORKFLOW_STEP_COMPLETED: return `Step ${payload.step_number}/${payload.total_steps}: ${payload.step_name || 'completed'}`; case EventType.WORKFLOW_COMPLETED: return payload.duration_seconds ? `Completed in ${payload.duration_seconds}s` : 'Workflow completed'; /* istanbul ignore next -- rarely used in ActivityFeed tests */ case EventType.WORKFLOW_FAILED: return payload.error_message ? String(payload.error_message) : 'Workflow failed'; /* istanbul ignore next -- defensive fallback */ default: return event.type; } } function groupEventsByTimePeriod(events: ProjectEvent[]): EventGroup[] { const groups: EventGroup[] = [ { label: 'Today', events: [] }, { label: 'Yesterday', events: [] }, { label: 'This Week', events: [] }, { label: 'Older', events: [] }, ]; events.forEach((event) => { const eventDate = new Date(event.timestamp); if (isToday(eventDate)) { groups[0].events.push(event); } else if (isYesterday(eventDate)) { groups[1].events.push(event); } else if (isThisWeek(eventDate, { weekStartsOn: 1 })) { groups[2].events.push(event); } else { groups[3].events.push(event); } }); // Sort events within each group by timestamp (newest first) groups.forEach((group) => { group.events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); }); return groups.filter((g) => g.events.length > 0); } function formatActorDisplay(event: ProjectEvent): string { if (event.actor_type === 'system') return 'System'; if (event.actor_type === 'agent') return 'Agent'; if (event.actor_type === 'user') return 'User'; /* istanbul ignore next -- defensive fallback for unknown actor types */ return event.actor_type; } // ============================================================================ // Sub-Components // ============================================================================ interface ConnectionIndicatorProps { state: ConnectionState; onReconnect?: () => void; className?: string; } function ConnectionIndicator({ state, onReconnect, className }: ConnectionIndicatorProps) { const statusConfig = { connected: { color: 'bg-green-500', label: 'Live', pulse: true }, connecting: { color: 'bg-yellow-500', label: 'Connecting...', pulse: true }, disconnected: { color: 'bg-gray-400', label: 'Disconnected', pulse: false }, error: { color: 'bg-red-500', label: 'Error', pulse: false }, }; const config = statusConfig[state]; const canReconnect = state === 'disconnected' || state === 'error'; return (
); } interface FilterPanelProps { selectedCategories: string[]; onCategoryChange: (categoryId: string) => void; showPendingOnly: boolean; onShowPendingOnlyChange: (value: boolean) => void; onClearFilters: () => void; events: ProjectEvent[]; } function FilterPanel({ selectedCategories, onCategoryChange, showPendingOnly, onShowPendingOnlyChange, onClearFilters, events, }: FilterPanelProps) { const getCategoryCount = (types: EventType[]) => { return events.filter((e) => types.includes(e.type)).length; }; const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length; return (
{FILTER_CATEGORIES.map((category) => { const count = getCategoryCount(category.types); return (
onCategoryChange(category.id)} />
); })}
onShowPendingOnlyChange(checked as boolean)} />
); } interface EventItemProps { event: ProjectEvent; expanded: boolean; onToggle: () => void; onApprove?: (event: ProjectEvent) => void; onReject?: (event: ProjectEvent) => void; onClick?: (event: ProjectEvent) => void; compact?: boolean; } function EventItem({ event, expanded, onToggle, onApprove, onReject, onClick, compact = false, }: EventItemProps) { const config = getEventConfig(event); const Icon = config.icon; const summary = getEventSummary(event); const actor = formatActorDisplay(event); const timestamp = formatDistanceToNow(new Date(event.timestamp), { addSuffix: true }); const isPendingApproval = event.type === EventType.APPROVAL_REQUESTED; const payload = event.payload as Record; const handleClick = () => { onClick?.(event); onToggle(); }; const handleApprove = (e: React.MouseEvent) => { e.stopPropagation(); onApprove?.(event); }; const handleReject = (e: React.MouseEvent) => { e.stopPropagation(); onReject?.(event); }; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }} data-testid={`event-item-${event.id}`} >
{/* Icon */}
{/* Content */}
{config.label} {actor} {isPendingApproval && ( Action Required )}

{summary}

{timestamp}
{/* Expanded Details */} {expanded && (() => { const issueId = payload.issue_id as string | undefined; const pullRequest = payload.pullRequest as string | number | undefined; const documentUrl = payload.documentUrl as string | undefined; const progress = payload.progress as number | undefined; return (
{/* Issue/PR Links */} {issueId && (
)} {pullRequest && (
)} {/* Document Links */} {documentUrl && ( )} {/* Progress */} {progress !== undefined && (
Progress {progress}%
)} {/* Timestamp */}

{new Date(event.timestamp).toLocaleString()}

{/* Raw Payload (for debugging) */}
View raw payload
                      {JSON.stringify(event.payload, null, 2)}
                    
); })()} {/* Approval Actions */} {isPendingApproval && (onApprove || onReject) && (
{onApprove && ( )} {onReject && ( )}
)}
); } function LoadingSkeleton() { return (
{[1, 2, 3].map((i) => (
))}
); } function EmptyState({ hasFilters }: { hasFilters: boolean }) { return (
); } // ============================================================================ // Main Component // ============================================================================ export function ActivityFeed({ events, connectionState, isLoading = false, onReconnect, onApprove, onReject, onEventClick, maxHeight = 'auto', showHeader = true, title = 'Activity Feed', enableFiltering = true, enableSearch = true, compact = false, className, }: ActivityFeedProps) { // State const [expandedEvents, setExpandedEvents] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [showFilters, setShowFilters] = useState(false); const [selectedCategories, setSelectedCategories] = useState([]); const [showPendingOnly, setShowPendingOnly] = useState(false); // Filter logic const filteredEvents = useMemo(() => { let result = events; // Search filter if (searchQuery) { const query = searchQuery.toLowerCase(); result = result.filter((event) => { const summary = getEventSummary(event).toLowerCase(); return summary.includes(query) || event.type.toLowerCase().includes(query); }); } // Category filter if (selectedCategories.length > 0) { const allowedTypes = new Set(); selectedCategories.forEach((categoryId) => { const category = FILTER_CATEGORIES.find((c) => c.id === categoryId); if (category) { category.types.forEach((type) => allowedTypes.add(type)); } }); result = result.filter((event) => allowedTypes.has(event.type)); } // Pending only filter if (showPendingOnly) { result = result.filter((event) => event.type === EventType.APPROVAL_REQUESTED); } return result; }, [events, searchQuery, selectedCategories, showPendingOnly]); // Group events by time const groupedEvents = useMemo(() => groupEventsByTimePeriod(filteredEvents), [filteredEvents]); // Event handlers const toggleExpanded = useCallback((eventId: string) => { setExpandedEvents((prev) => { const next = new Set(prev); if (next.has(eventId)) { next.delete(eventId); } else { next.add(eventId); } return next; }); }, []); const handleCategoryChange = useCallback((categoryId: string) => { setSelectedCategories((prev) => prev.includes(categoryId) ? prev.filter((c) => c !== categoryId) : [...prev, categoryId] ); }, []); const handleClearFilters = useCallback(() => { setSelectedCategories([]); setShowPendingOnly(false); setSearchQuery(''); }, []); const hasFilters = searchQuery !== '' || selectedCategories.length > 0 || showPendingOnly; const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length; return ( {showHeader && (
{title}
{pendingCount > 0 && ( {pendingCount} pending )}
)}
{/* Search and Filter Controls */} {(enableSearch || enableFiltering) && (
{enableSearch && (
setSearchQuery(e.target.value)} className="pl-9" data-testid="search-input" />
)} {enableFiltering && ( )}
)} {/* Filter Panel */} {showFilters && enableFiltering && ( )} {/* Event List */}
{isLoading ? ( ) : filteredEvents.length === 0 ? ( ) : (
{groupedEvents.map((group) => (

{group.label}

{group.events.length}
{group.events.map((event) => ( toggleExpanded(event.id)} onApprove={onApprove} onReject={onReject} onClick={onEventClick} compact={compact} /> ))}
))}
)}
); }