diff --git a/frontend/src/components/events/ConnectionStatus.tsx b/frontend/src/components/events/ConnectionStatus.tsx
new file mode 100644
index 0000000..efe9856
--- /dev/null
+++ b/frontend/src/components/events/ConnectionStatus.tsx
@@ -0,0 +1,214 @@
+/**
+ * ConnectionStatus Component
+ *
+ * Displays the current SSE connection state with visual indicators
+ * and reconnection controls.
+ */
+
+'use client';
+
+import { RefreshCw, Wifi, WifiOff, Loader2, AlertCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import type { ConnectionState, SSEError } from '@/lib/types/events';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ConnectionStatusProps {
+ /** Current connection state */
+ state: ConnectionState;
+ /** Current error, if any */
+ error?: SSEError | null;
+ /** Current retry attempt count */
+ retryCount?: number;
+ /** Callback to trigger reconnection */
+ onReconnect?: () => void;
+ /** Whether to show the reconnect button (default: true) */
+ showReconnectButton?: boolean;
+ /** Whether to show error details (default: true) */
+ showErrorDetails?: boolean;
+ /** Additional CSS classes */
+ className?: string;
+ /** Compact mode - smaller display */
+ compact?: boolean;
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function getStatusConfig(state: ConnectionState) {
+ switch (state) {
+ case 'connected':
+ return {
+ icon: Wifi,
+ label: 'Connected',
+ variant: 'default' as const,
+ iconClassName: 'text-green-500',
+ description: 'Receiving real-time updates',
+ };
+ case 'connecting':
+ return {
+ icon: Loader2,
+ label: 'Connecting',
+ variant: 'secondary' as const,
+ iconClassName: 'animate-spin text-muted-foreground',
+ description: 'Establishing connection...',
+ };
+ case 'disconnected':
+ return {
+ icon: WifiOff,
+ label: 'Disconnected',
+ variant: 'outline' as const,
+ iconClassName: 'text-muted-foreground',
+ description: 'Not connected to server',
+ };
+ case 'error':
+ return {
+ icon: AlertCircle,
+ label: 'Connection Error',
+ variant: 'destructive' as const,
+ iconClassName: 'text-destructive',
+ description: 'Failed to connect',
+ };
+ default:
+ return {
+ icon: WifiOff,
+ label: 'Unknown',
+ variant: 'outline' as const,
+ iconClassName: 'text-muted-foreground',
+ description: 'Unknown state',
+ };
+ }
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+/**
+ * ConnectionStatus - Display SSE connection state
+ *
+ * Features:
+ * - Visual state indicator with icon
+ * - Connection state badge
+ * - Error message display
+ * - Retry count display
+ * - Reconnect button
+ * - Compact mode for inline use
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ConnectionStatus({
+ state,
+ error,
+ retryCount = 0,
+ onReconnect,
+ showReconnectButton = true,
+ showErrorDetails = true,
+ className,
+ compact = false,
+}: ConnectionStatusProps) {
+ const { icon: Icon, label, variant, iconClassName, description } = getStatusConfig(state);
+
+ const canReconnect = state === 'disconnected' || state === 'error';
+ const isRetrying = state === 'connecting' && retryCount > 0;
+
+ if (compact) {
+ return (
+
+
+
+ {label}
+ {isRetrying && ` (retry ${retryCount})`}
+
+ {showReconnectButton && canReconnect && onReconnect && (
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {/* Header with icon and status */}
+
+
+
+
+
+
+
+ {label}
+ {isRetrying && (
+
+ Retry {retryCount}
+
+ )}
+
+
{description}
+
+
+
+ {/* Reconnect button */}
+ {showReconnectButton && canReconnect && onReconnect && (
+
+ )}
+
+
+ {/* Error details */}
+ {showErrorDetails && error && (
+
+
Error: {error.message}
+ {error.code && (
+
+ Code: {error.code}
+
+ )}
+
+ {new Date(error.timestamp).toLocaleTimeString()}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/events/EventList.tsx b/frontend/src/components/events/EventList.tsx
new file mode 100644
index 0000000..d62f148
--- /dev/null
+++ b/frontend/src/components/events/EventList.tsx
@@ -0,0 +1,467 @@
+/**
+ * EventList Component
+ *
+ * Displays a list of project events with:
+ * - Event type icons and styling
+ * - Relative timestamps
+ * - Actor information
+ * - Expandable payload details
+ */
+
+'use client';
+
+import { useState } from 'react';
+import { formatDistanceToNow } from 'date-fns';
+import {
+ Bot,
+ FileText,
+ Users,
+ CheckCircle2,
+ XCircle,
+ PlayCircle,
+ AlertTriangle,
+ Folder,
+ Workflow,
+ ChevronDown,
+ ChevronRight,
+} 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 {
+ EventType,
+ type ProjectEvent,
+ isAgentEvent,
+ isIssueEvent,
+ isSprintEvent,
+ isApprovalEvent,
+ isWorkflowEvent,
+ isProjectEvent,
+} from '@/lib/types/events';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface EventListProps {
+ /** Events to display */
+ events: ProjectEvent[];
+ /** Maximum height for scrolling (default: 400px) */
+ maxHeight?: number | string;
+ /** Whether to show the header (default: true) */
+ showHeader?: boolean;
+ /** Title for the header (default: 'Activity Feed') */
+ title?: string;
+ /** Whether to show event payloads (default: false) */
+ showPayloads?: boolean;
+ /** Empty state message */
+ emptyMessage?: string;
+ /** Additional CSS classes */
+ className?: string;
+ /** Callback when event is clicked */
+ onEventClick?: (event: ProjectEvent) => void;
+}
+
+interface EventItemProps {
+ /** Event to display */
+ event: ProjectEvent;
+ /** Whether to show payload details */
+ showPayload?: boolean;
+ /** Callback when clicked */
+ onClick?: (event: ProjectEvent) => void;
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function getEventConfig(event: ProjectEvent) {
+ if (isAgentEvent(event)) {
+ switch (event.type) {
+ case EventType.AGENT_SPAWNED:
+ return {
+ icon: Bot,
+ label: 'Agent Spawned',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900',
+ };
+ case EventType.AGENT_MESSAGE:
+ return {
+ icon: Bot,
+ label: 'Agent Message',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900',
+ };
+ case EventType.AGENT_STATUS_CHANGED:
+ return {
+ icon: Bot,
+ label: 'Status Changed',
+ color: 'text-yellow-500',
+ bgColor: 'bg-yellow-100 dark:bg-yellow-900',
+ };
+ case EventType.AGENT_TERMINATED:
+ return {
+ icon: Bot,
+ label: 'Agent Terminated',
+ color: 'text-gray-500',
+ bgColor: 'bg-gray-100 dark:bg-gray-800',
+ };
+ }
+ }
+
+ if (isIssueEvent(event)) {
+ switch (event.type) {
+ case EventType.ISSUE_CREATED:
+ return {
+ icon: FileText,
+ label: 'Issue Created',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900',
+ };
+ case EventType.ISSUE_UPDATED:
+ return {
+ icon: FileText,
+ label: 'Issue Updated',
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-100 dark:bg-blue-900',
+ };
+ case EventType.ISSUE_ASSIGNED:
+ return {
+ icon: Users,
+ label: 'Issue Assigned',
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-100 dark:bg-purple-900',
+ };
+ case EventType.ISSUE_CLOSED:
+ return {
+ icon: CheckCircle2,
+ label: 'Issue Closed',
+ color: 'text-green-600',
+ bgColor: 'bg-green-100 dark:bg-green-900',
+ };
+ }
+ }
+
+ if (isSprintEvent(event)) {
+ return {
+ icon: PlayCircle,
+ label: event.type === EventType.SPRINT_STARTED ? 'Sprint Started' : 'Sprint Completed',
+ color: 'text-indigo-500',
+ bgColor: 'bg-indigo-100 dark:bg-indigo-900',
+ };
+ }
+
+ if (isApprovalEvent(event)) {
+ switch (event.type) {
+ case EventType.APPROVAL_REQUESTED:
+ return {
+ icon: AlertTriangle,
+ label: 'Approval Requested',
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-100 dark:bg-orange-900',
+ };
+ case EventType.APPROVAL_GRANTED:
+ return {
+ icon: CheckCircle2,
+ label: 'Approval Granted',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900',
+ };
+ case EventType.APPROVAL_DENIED:
+ return {
+ icon: XCircle,
+ label: 'Approval Denied',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900',
+ };
+ }
+ }
+
+ if (isProjectEvent(event)) {
+ return {
+ icon: Folder,
+ label: event.type.replace('project.', '').replace('_', ' '),
+ color: 'text-teal-500',
+ bgColor: 'bg-teal-100 dark:bg-teal-900',
+ };
+ }
+
+ if (isWorkflowEvent(event)) {
+ switch (event.type) {
+ case EventType.WORKFLOW_STARTED:
+ return {
+ icon: Workflow,
+ label: 'Workflow Started',
+ color: 'text-cyan-500',
+ bgColor: 'bg-cyan-100 dark:bg-cyan-900',
+ };
+ case EventType.WORKFLOW_STEP_COMPLETED:
+ return {
+ icon: Workflow,
+ label: 'Step Completed',
+ color: 'text-cyan-500',
+ bgColor: 'bg-cyan-100 dark:bg-cyan-900',
+ };
+ case EventType.WORKFLOW_COMPLETED:
+ return {
+ icon: CheckCircle2,
+ label: 'Workflow Completed',
+ color: 'text-green-500',
+ bgColor: 'bg-green-100 dark:bg-green-900',
+ };
+ case EventType.WORKFLOW_FAILED:
+ return {
+ icon: XCircle,
+ label: 'Workflow Failed',
+ color: 'text-red-500',
+ bgColor: 'bg-red-100 dark:bg-red-900',
+ };
+ }
+ }
+
+ // Default fallback
+ return {
+ icon: FileText,
+ 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) {
+ 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');
+ case EventType.AGENT_STATUS_CHANGED:
+ return `Status: ${payload.previous_status} -> ${payload.new_status}`;
+ 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');
+ case EventType.ISSUE_UPDATED:
+ return `Issue ${payload.issue_id || ''} updated`;
+ case EventType.ISSUE_ASSIGNED:
+ return payload.assignee_name
+ ? `Assigned to ${payload.assignee_name}`
+ : 'Issue assignment changed';
+ 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';
+ 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');
+ case EventType.APPROVAL_GRANTED:
+ return 'Approval granted';
+ case EventType.APPROVAL_DENIED:
+ return payload.reason ? `Denied: ${payload.reason}` : 'Approval denied';
+ case EventType.WORKFLOW_STARTED:
+ return payload.workflow_type
+ ? `${payload.workflow_type} workflow started`
+ : 'Workflow started';
+ 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';
+ case EventType.WORKFLOW_FAILED:
+ return payload.error_message
+ ? String(payload.error_message)
+ : 'Workflow failed';
+ default:
+ return event.type;
+ }
+}
+
+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';
+ return event.actor_type;
+}
+
+// ============================================================================
+// EventItem Component
+// ============================================================================
+
+function EventItem({ event, showPayload = false, onClick }: EventItemProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ 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 handleClick = () => {
+ if (showPayload) {
+ setIsExpanded(!isExpanded);
+ }
+ onClick?.(event);
+ };
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleClick();
+ }
+ }
+ : undefined
+ }
+ >
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+
+
+
+
+
+ {config.label}
+
+ {actor}
+
+
{summary}
+
+
+
+ {timestamp}
+ {showPayload && (
+
+ )}
+
+
+
+ {/* Expanded payload */}
+ {showPayload && isExpanded && (
+
+
+ {JSON.stringify(event.payload, null, 2)}
+
+
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// EventList Component
+// ============================================================================
+
+/**
+ * EventList - Display project activity feed
+ *
+ * Features:
+ * - Event type icons and colors
+ * - Event summaries
+ * - Relative timestamps
+ * - Actor display
+ * - Expandable payload details
+ * - Scrollable container
+ * - Empty state handling
+ *
+ * @example
+ * ```tsx
+ * console.log('Clicked:', e)}
+ * />
+ * ```
+ */
+export function EventList({
+ events,
+ maxHeight = 400,
+ showHeader = true,
+ title = 'Activity Feed',
+ showPayloads = false,
+ emptyMessage = 'No events yet',
+ className,
+ onEventClick,
+}: EventListProps) {
+ // Sort events by timestamp, newest first
+ const sortedEvents = [...events].sort(
+ (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
+ );
+
+ return (
+
+ {showHeader && (
+
+
+ {title}
+ {events.length > 0 && (
+
+ {events.length} event{events.length !== 1 ? 's' : ''}
+
+ )}
+
+
+ )}
+
+ {sortedEvents.length === 0 ? (
+
+ ) : (
+
+
+ {sortedEvents.map((event) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/events/index.ts b/frontend/src/components/events/index.ts
new file mode 100644
index 0000000..50bcbb9
--- /dev/null
+++ b/frontend/src/components/events/index.ts
@@ -0,0 +1,10 @@
+/**
+ * Event Components
+ *
+ * Components for displaying real-time project events via SSE.
+ *
+ * @module components/events
+ */
+
+export { ConnectionStatus } from './ConnectionStatus';
+export { EventList } from './EventList';
diff --git a/frontend/src/lib/hooks/index.ts b/frontend/src/lib/hooks/index.ts
new file mode 100644
index 0000000..00fbe9b
--- /dev/null
+++ b/frontend/src/lib/hooks/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Custom React Hooks
+ *
+ * @module lib/hooks
+ */
+
+export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';
diff --git a/frontend/src/lib/hooks/useProjectEvents.ts b/frontend/src/lib/hooks/useProjectEvents.ts
new file mode 100644
index 0000000..beff1f2
--- /dev/null
+++ b/frontend/src/lib/hooks/useProjectEvents.ts
@@ -0,0 +1,393 @@
+/**
+ * SSE Hook for Project Events
+ *
+ * Provides real-time event streaming from the backend with:
+ * - Automatic reconnection with exponential backoff
+ * - Connection state management
+ * - Type-safe event handling
+ * - Integration with event store
+ *
+ * @module lib/hooks/useProjectEvents
+ */
+
+'use client';
+
+import { useEffect, useRef, useCallback, useState } from 'react';
+import { useAuth } from '@/lib/auth/AuthContext';
+import { useEventStore, useProjectEventsFromStore } from '@/lib/stores/eventStore';
+import type { ProjectEvent, ConnectionState, SSEError } from '@/lib/types/events';
+import config from '@/config/app.config';
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/** Initial retry delay in milliseconds */
+const INITIAL_RETRY_DELAY = 1000;
+
+/** Maximum retry delay in milliseconds (30 seconds) */
+const MAX_RETRY_DELAY = 30000;
+
+/** Maximum number of retry attempts before giving up (0 = unlimited) */
+const MAX_RETRY_ATTEMPTS = 0;
+
+/** Backoff multiplier for exponential backoff */
+const BACKOFF_MULTIPLIER = 2;
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface UseProjectEventsOptions {
+ /** Enable automatic connection on mount (default: true) */
+ autoConnect?: boolean;
+ /** Custom retry delay in milliseconds (default: 1000) */
+ initialRetryDelay?: number;
+ /** Maximum retry delay in milliseconds (default: 30000) */
+ maxRetryDelay?: number;
+ /** Maximum retry attempts (0 = unlimited, default: 0) */
+ maxRetryAttempts?: number;
+ /** Callback when event is received */
+ onEvent?: (event: ProjectEvent) => void;
+ /** Callback when connection state changes */
+ onConnectionChange?: (state: ConnectionState) => void;
+ /** Callback when error occurs */
+ onError?: (error: SSEError) => void;
+}
+
+export interface UseProjectEventsResult {
+ /** Events for the project (from store) */
+ events: ProjectEvent[];
+ /** Whether connection is established */
+ isConnected: boolean;
+ /** Current connection state */
+ connectionState: ConnectionState;
+ /** Current error, if any */
+ error: SSEError | null;
+ /** Current retry attempt count */
+ retryCount: number;
+ /** Manually trigger reconnection */
+ reconnect: () => void;
+ /** Disconnect from SSE */
+ disconnect: () => void;
+ /** Clear events for this project */
+ clearEvents: () => void;
+}
+
+// ============================================================================
+// Hook Implementation
+// ============================================================================
+
+/**
+ * Hook for consuming real-time project events via SSE
+ *
+ * @param projectId - Project ID to subscribe to
+ * @param options - Configuration options
+ * @returns Event data and connection controls
+ *
+ * @example
+ * ```tsx
+ * const { events, isConnected, error, reconnect } = useProjectEvents('project-123');
+ *
+ * if (!isConnected) {
+ * return ;
+ * }
+ *
+ * return ;
+ * ```
+ */
+export function useProjectEvents(
+ projectId: string,
+ options: UseProjectEventsOptions = {}
+): UseProjectEventsResult {
+ const {
+ autoConnect = true,
+ initialRetryDelay = INITIAL_RETRY_DELAY,
+ maxRetryDelay = MAX_RETRY_DELAY,
+ maxRetryAttempts = MAX_RETRY_ATTEMPTS,
+ onEvent,
+ onConnectionChange,
+ onError,
+ } = options;
+
+ // Auth state
+ const { accessToken, isAuthenticated } = useAuth();
+
+ // Event store
+ const events = useProjectEventsFromStore(projectId);
+ const addEvent = useEventStore((state) => state.addEvent);
+ const clearProjectEvents = useEventStore((state) => state.clearProjectEvents);
+
+ // Local state
+ const [connectionState, setConnectionState] = useState('disconnected');
+ const [error, setError] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+
+ // Refs for cleanup and reconnection logic
+ const eventSourceRef = useRef(null);
+ const retryTimeoutRef = useRef | null>(null);
+ const currentRetryDelayRef = useRef(initialRetryDelay);
+ const isManualDisconnectRef = useRef(false);
+ const mountedRef = useRef(true);
+
+ /**
+ * Update connection state and notify callback
+ */
+ const updateConnectionState = useCallback(
+ (state: ConnectionState) => {
+ if (!mountedRef.current) return;
+ setConnectionState(state);
+ onConnectionChange?.(state);
+ },
+ [onConnectionChange]
+ );
+
+ /**
+ * Handle SSE error
+ */
+ const handleError = useCallback(
+ (message: string, code?: string) => {
+ if (!mountedRef.current) return;
+
+ const sseError: SSEError = {
+ message,
+ code,
+ timestamp: new Date().toISOString(),
+ retryAttempt: retryCount + 1,
+ };
+
+ setError(sseError);
+ onError?.(sseError);
+ },
+ [retryCount, onError]
+ );
+
+ /**
+ * Parse and validate event data
+ */
+ const parseEvent = useCallback((data: string): ProjectEvent | null => {
+ try {
+ const parsed = JSON.parse(data);
+
+ // Validate required fields
+ if (!parsed.id || !parsed.type || !parsed.timestamp || !parsed.project_id) {
+ console.warn('[SSE] Invalid event structure:', parsed);
+ return null;
+ }
+
+ return parsed as ProjectEvent;
+ } catch (err) {
+ console.warn('[SSE] Failed to parse event data:', err);
+ return null;
+ }
+ }, []);
+
+ /**
+ * Close existing connection and clear retry timeout
+ */
+ const cleanup = useCallback(() => {
+ if (retryTimeoutRef.current) {
+ clearTimeout(retryTimeoutRef.current);
+ retryTimeoutRef.current = null;
+ }
+
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ eventSourceRef.current = null;
+ }
+ }, []);
+
+ /**
+ * Calculate next retry delay with exponential backoff
+ */
+ const getNextRetryDelay = useCallback(() => {
+ const nextDelay = currentRetryDelayRef.current * BACKOFF_MULTIPLIER;
+ currentRetryDelayRef.current = Math.min(nextDelay, maxRetryDelay);
+ return currentRetryDelayRef.current;
+ }, [maxRetryDelay]);
+
+ /**
+ * Schedule reconnection attempt
+ */
+ const scheduleReconnect = useCallback(() => {
+ if (isManualDisconnectRef.current) return;
+ if (maxRetryAttempts > 0 && retryCount >= maxRetryAttempts) {
+ console.warn('[SSE] Max retry attempts reached');
+ updateConnectionState('error');
+ return;
+ }
+
+ const delay = getNextRetryDelay();
+
+ if (config.debug.api) {
+ console.log(`[SSE] Scheduling reconnect in ${delay}ms (attempt ${retryCount + 1})`);
+ }
+
+ retryTimeoutRef.current = setTimeout(() => {
+ if (!mountedRef.current || isManualDisconnectRef.current) return;
+ setRetryCount((prev) => prev + 1);
+ connect();
+ }, delay);
+ }, [retryCount, maxRetryAttempts, getNextRetryDelay, updateConnectionState]);
+
+ /**
+ * Connect to SSE endpoint
+ */
+ const connect = useCallback(() => {
+ // Prevent connection if not authenticated or no project ID
+ if (!isAuthenticated || !accessToken || !projectId) {
+ if (config.debug.api) {
+ console.log('[SSE] Cannot connect: missing auth or projectId');
+ }
+ return;
+ }
+
+ // Clean up existing connection
+ cleanup();
+ isManualDisconnectRef.current = false;
+
+ updateConnectionState('connecting');
+ setError(null);
+
+ // Build SSE URL with auth token
+ const baseUrl = config.api.url;
+ const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events`;
+
+ // Note: EventSource doesn't support custom headers natively
+ // We pass the token as a query parameter (backend should validate this)
+ const urlWithAuth = `${sseUrl}?token=${encodeURIComponent(accessToken)}`;
+
+ try {
+ const eventSource = new EventSource(urlWithAuth);
+ eventSourceRef.current = eventSource;
+
+ eventSource.onopen = () => {
+ if (!mountedRef.current) return;
+
+ if (config.debug.api) {
+ console.log('[SSE] Connection opened');
+ }
+
+ updateConnectionState('connected');
+ setRetryCount(0);
+ currentRetryDelayRef.current = initialRetryDelay;
+ };
+
+ eventSource.onmessage = (event) => {
+ if (!mountedRef.current) return;
+
+ const parsedEvent = parseEvent(event.data);
+ if (parsedEvent) {
+ // Add to store
+ addEvent(parsedEvent);
+ // Notify callback
+ onEvent?.(parsedEvent);
+ }
+ };
+
+ // Handle specific event types from backend
+ eventSource.addEventListener('ping', () => {
+ // Keep-alive ping from server, no action needed
+ if (config.debug.api) {
+ console.log('[SSE] Received ping');
+ }
+ });
+
+ eventSource.onerror = (err) => {
+ if (!mountedRef.current) return;
+
+ console.error('[SSE] Connection error:', err);
+
+ if (eventSource.readyState === EventSource.CLOSED) {
+ handleError('Connection closed unexpectedly', 'CONNECTION_CLOSED');
+ updateConnectionState('disconnected');
+ scheduleReconnect();
+ } else {
+ handleError('Connection error', 'CONNECTION_ERROR');
+ updateConnectionState('error');
+ scheduleReconnect();
+ }
+ };
+ } catch (err) {
+ console.error('[SSE] Failed to create EventSource:', err);
+ handleError('Failed to create connection', 'CREATION_FAILED');
+ updateConnectionState('error');
+ scheduleReconnect();
+ }
+ }, [
+ isAuthenticated,
+ accessToken,
+ projectId,
+ cleanup,
+ updateConnectionState,
+ handleError,
+ parseEvent,
+ addEvent,
+ onEvent,
+ scheduleReconnect,
+ initialRetryDelay,
+ ]);
+
+ /**
+ * Manually disconnect from SSE
+ */
+ const disconnect = useCallback(() => {
+ isManualDisconnectRef.current = true;
+ cleanup();
+ updateConnectionState('disconnected');
+ setRetryCount(0);
+ currentRetryDelayRef.current = initialRetryDelay;
+ }, [cleanup, updateConnectionState, initialRetryDelay]);
+
+ /**
+ * Manually trigger reconnection
+ */
+ const reconnect = useCallback(() => {
+ disconnect();
+ isManualDisconnectRef.current = false;
+ connect();
+ }, [disconnect, connect]);
+
+ /**
+ * Clear events for this project
+ */
+ const clearEvents = useCallback(() => {
+ clearProjectEvents(projectId);
+ }, [clearProjectEvents, projectId]);
+
+ // Auto-connect on mount if enabled
+ useEffect(() => {
+ mountedRef.current = true;
+
+ if (autoConnect && isAuthenticated && projectId) {
+ connect();
+ }
+
+ return () => {
+ mountedRef.current = false;
+ cleanup();
+ };
+ }, [autoConnect, isAuthenticated, projectId, connect, cleanup]);
+
+ // Reconnect when auth changes
+ useEffect(() => {
+ if (isAuthenticated && accessToken && connectionState === 'disconnected' && autoConnect) {
+ if (!isManualDisconnectRef.current) {
+ connect();
+ }
+ } else if (!isAuthenticated && connectionState !== 'disconnected') {
+ disconnect();
+ }
+ }, [isAuthenticated, accessToken, connectionState, autoConnect, connect, disconnect]);
+
+ return {
+ events,
+ isConnected: connectionState === 'connected',
+ connectionState,
+ error,
+ retryCount,
+ reconnect,
+ disconnect,
+ clearEvents,
+ };
+}
diff --git a/frontend/src/lib/stores/eventStore.ts b/frontend/src/lib/stores/eventStore.ts
new file mode 100644
index 0000000..a543849
--- /dev/null
+++ b/frontend/src/lib/stores/eventStore.ts
@@ -0,0 +1,225 @@
+/**
+ * Event Store - Zustand store for project events
+ *
+ * Manages real-time events received via SSE with:
+ * - Event buffer (configurable, default 100 events)
+ * - Per-project event management
+ * - Event filtering utilities
+ *
+ * @module lib/stores/eventStore
+ */
+
+import { create } from 'zustand';
+import type { ProjectEvent, EventType } from '@/lib/types/events';
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/** Maximum number of events to keep in buffer per project */
+const DEFAULT_MAX_EVENTS = 100;
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface EventState {
+ /** Events indexed by project ID */
+ eventsByProject: Record;
+
+ /** Maximum events to keep per project */
+ maxEvents: number;
+}
+
+interface EventActions {
+ /**
+ * Add an event to the store
+ * @param event - The event to add
+ */
+ addEvent: (event: ProjectEvent) => void;
+
+ /**
+ * Add multiple events at once
+ * @param events - Array of events to add
+ */
+ addEvents: (events: ProjectEvent[]) => void;
+
+ /**
+ * Clear all events for a specific project
+ * @param projectId - Project ID to clear events for
+ */
+ clearProjectEvents: (projectId: string) => void;
+
+ /**
+ * Clear all events from the store
+ */
+ clearAllEvents: () => void;
+
+ /**
+ * Get events for a specific project
+ * @param projectId - Project ID
+ * @returns Array of events for the project
+ */
+ getProjectEvents: (projectId: string) => ProjectEvent[];
+
+ /**
+ * Get events filtered by type
+ * @param projectId - Project ID
+ * @param types - Event types to filter by
+ * @returns Filtered array of events
+ */
+ getFilteredEvents: (projectId: string, types: EventType[]) => ProjectEvent[];
+
+ /**
+ * Set the maximum number of events to keep per project
+ * @param max - Maximum event count
+ */
+ setMaxEvents: (max: number) => void;
+}
+
+export type EventStore = EventState & EventActions;
+
+// ============================================================================
+// Store Implementation
+// ============================================================================
+
+export const useEventStore = create((set, get) => ({
+ // Initial state
+ eventsByProject: {},
+ maxEvents: DEFAULT_MAX_EVENTS,
+
+ addEvent: (event: ProjectEvent) => {
+ set((state) => {
+ const projectId = event.project_id;
+ const existingEvents = state.eventsByProject[projectId] || [];
+
+ // Check for duplicate event IDs
+ if (existingEvents.some((e) => e.id === event.id)) {
+ return state; // Skip duplicate
+ }
+
+ // Add new event and trim to max
+ const updatedEvents = [...existingEvents, event].slice(-state.maxEvents);
+
+ return {
+ eventsByProject: {
+ ...state.eventsByProject,
+ [projectId]: updatedEvents,
+ },
+ };
+ });
+ },
+
+ addEvents: (events: ProjectEvent[]) => {
+ if (events.length === 0) return;
+
+ set((state) => {
+ const updatedEventsByProject = { ...state.eventsByProject };
+
+ // Group events by project
+ const eventsByProjectId = events.reduce(
+ (acc, event) => {
+ if (!acc[event.project_id]) {
+ acc[event.project_id] = [];
+ }
+ acc[event.project_id].push(event);
+ return acc;
+ },
+ {} as Record
+ );
+
+ // Merge events for each project
+ for (const [projectId, newEvents] of Object.entries(eventsByProjectId)) {
+ const existingEvents = updatedEventsByProject[projectId] || [];
+
+ // Filter out duplicates
+ const existingIds = new Set(existingEvents.map((e) => e.id));
+ const uniqueNewEvents = newEvents.filter((e) => !existingIds.has(e.id));
+
+ // Merge and trim
+ updatedEventsByProject[projectId] = [...existingEvents, ...uniqueNewEvents].slice(
+ -state.maxEvents
+ );
+ }
+
+ return { eventsByProject: updatedEventsByProject };
+ });
+ },
+
+ clearProjectEvents: (projectId: string) => {
+ set((state) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [projectId]: _removed, ...rest } = state.eventsByProject;
+ return { eventsByProject: rest };
+ });
+ },
+
+ clearAllEvents: () => {
+ set({ eventsByProject: {} });
+ },
+
+ getProjectEvents: (projectId: string) => {
+ return get().eventsByProject[projectId] || [];
+ },
+
+ getFilteredEvents: (projectId: string, types: EventType[]) => {
+ const events = get().eventsByProject[projectId] || [];
+ if (types.length === 0) return events;
+
+ const typeSet = new Set(types);
+ return events.filter((event) => typeSet.has(event.type));
+ },
+
+ setMaxEvents: (max: number) => {
+ if (max < 1) {
+ console.warn(`Invalid maxEvents value: ${max}, using default: ${DEFAULT_MAX_EVENTS}`);
+ max = DEFAULT_MAX_EVENTS;
+ }
+
+ set((state) => {
+ // Trim existing events if necessary
+ const trimmedEventsByProject: Record = {};
+
+ for (const [projectId, events] of Object.entries(state.eventsByProject)) {
+ trimmedEventsByProject[projectId] = events.slice(-max);
+ }
+
+ return {
+ maxEvents: max,
+ eventsByProject: trimmedEventsByProject,
+ };
+ });
+ },
+}));
+
+// ============================================================================
+// Selector Hooks
+// ============================================================================
+
+/**
+ * Hook to get events for a specific project
+ * @param projectId - Project ID
+ * @returns Array of events for the project
+ */
+export function useProjectEventsFromStore(projectId: string): ProjectEvent[] {
+ return useEventStore((state) => state.eventsByProject[projectId] || []);
+}
+
+/**
+ * Hook to get the latest event for a project
+ * @param projectId - Project ID
+ * @returns Latest event or undefined
+ */
+export function useLatestEvent(projectId: string): ProjectEvent | undefined {
+ const events = useEventStore((state) => state.eventsByProject[projectId] || []);
+ return events[events.length - 1];
+}
+
+/**
+ * Hook to get event count for a project
+ * @param projectId - Project ID
+ * @returns Number of events
+ */
+export function useEventCount(projectId: string): number {
+ return useEventStore((state) => (state.eventsByProject[projectId] || []).length);
+}
diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts
index 04e47ff..c1656a2 100755
--- a/frontend/src/lib/stores/index.ts
+++ b/frontend/src/lib/stores/index.ts
@@ -3,5 +3,14 @@
export { useAuthStore, initializeAuth, type User } from './authStore';
+// Event Store for SSE events
+export {
+ useEventStore,
+ useProjectEventsFromStore,
+ useLatestEvent,
+ useEventCount,
+ type EventStore,
+} from './eventStore';
+
// Authentication Context (DI wrapper for auth store)
export { useAuth, AuthProvider } from '../auth/AuthContext';
diff --git a/frontend/src/lib/types/events.ts b/frontend/src/lib/types/events.ts
new file mode 100644
index 0000000..46810d4
--- /dev/null
+++ b/frontend/src/lib/types/events.ts
@@ -0,0 +1,307 @@
+/**
+ * Event Types and Interfaces for SSE
+ *
+ * These types mirror the backend event schemas from backend/app/schemas/events.py
+ * for type-safe event handling in the frontend.
+ *
+ * @module lib/types/events
+ */
+
+// ============================================================================
+// Event Type Enum
+// ============================================================================
+
+/**
+ * Event types matching backend EventType enum.
+ * Naming convention: {domain}.{action}
+ */
+export enum EventType {
+ // Agent Events
+ AGENT_SPAWNED = 'agent.spawned',
+ AGENT_STATUS_CHANGED = 'agent.status_changed',
+ AGENT_MESSAGE = 'agent.message',
+ AGENT_TERMINATED = 'agent.terminated',
+
+ // Issue Events
+ ISSUE_CREATED = 'issue.created',
+ ISSUE_UPDATED = 'issue.updated',
+ ISSUE_ASSIGNED = 'issue.assigned',
+ ISSUE_CLOSED = 'issue.closed',
+
+ // Sprint Events
+ SPRINT_STARTED = 'sprint.started',
+ SPRINT_COMPLETED = 'sprint.completed',
+
+ // Approval Events
+ APPROVAL_REQUESTED = 'approval.requested',
+ APPROVAL_GRANTED = 'approval.granted',
+ APPROVAL_DENIED = 'approval.denied',
+
+ // Project Events
+ PROJECT_CREATED = 'project.created',
+ PROJECT_UPDATED = 'project.updated',
+ PROJECT_ARCHIVED = 'project.archived',
+
+ // Workflow Events
+ WORKFLOW_STARTED = 'workflow.started',
+ WORKFLOW_STEP_COMPLETED = 'workflow.step_completed',
+ WORKFLOW_COMPLETED = 'workflow.completed',
+ WORKFLOW_FAILED = 'workflow.failed',
+}
+
+// ============================================================================
+// Actor Types
+// ============================================================================
+
+/**
+ * Type of actor who triggered an event
+ */
+export type ActorType = 'agent' | 'user' | 'system';
+
+// ============================================================================
+// Base Event Interface
+// ============================================================================
+
+/**
+ * Base event schema matching backend Event model.
+ * All events from the EventBus conform to this schema.
+ */
+export interface ProjectEvent {
+ /** Unique event identifier (UUID string) */
+ id: string;
+ /** Event type enum value */
+ type: EventType;
+ /** When the event occurred (ISO 8601 UTC) */
+ timestamp: string;
+ /** Project this event belongs to (UUID string) */
+ project_id: string;
+ /** ID of the agent or user who triggered the event (UUID string) */
+ actor_id: string | null;
+ /** Type of actor: 'agent', 'user', or 'system' */
+ actor_type: ActorType;
+ /** Event-specific payload data */
+ payload: EventPayload;
+}
+
+// ============================================================================
+// Payload Types
+// ============================================================================
+
+/**
+ * Union type for all possible event payloads
+ */
+export type EventPayload =
+ | AgentSpawnedPayload
+ | AgentStatusChangedPayload
+ | AgentMessagePayload
+ | AgentTerminatedPayload
+ | IssueCreatedPayload
+ | IssueUpdatedPayload
+ | IssueAssignedPayload
+ | IssueClosedPayload
+ | SprintStartedPayload
+ | SprintCompletedPayload
+ | ApprovalRequestedPayload
+ | ApprovalGrantedPayload
+ | ApprovalDeniedPayload
+ | WorkflowStartedPayload
+ | WorkflowStepCompletedPayload
+ | WorkflowCompletedPayload
+ | WorkflowFailedPayload
+ | Record;
+
+// Agent Payloads
+
+export interface AgentSpawnedPayload {
+ agent_instance_id: string;
+ agent_type_id: string;
+ agent_name: string;
+ role: string;
+}
+
+export interface AgentStatusChangedPayload {
+ agent_instance_id: string;
+ previous_status: string;
+ new_status: string;
+ reason?: string | null;
+}
+
+export interface AgentMessagePayload {
+ agent_instance_id: string;
+ message: string;
+ message_type: 'info' | 'warning' | 'error' | 'debug';
+ metadata?: Record;
+}
+
+export interface AgentTerminatedPayload {
+ agent_instance_id: string;
+ termination_reason: string;
+ final_status: string;
+}
+
+// Issue Payloads
+
+export interface IssueCreatedPayload {
+ issue_id: string;
+ title: string;
+ priority?: string | null;
+ labels?: string[];
+}
+
+export interface IssueUpdatedPayload {
+ issue_id: string;
+ changes: Record;
+}
+
+export interface IssueAssignedPayload {
+ issue_id: string;
+ assignee_id?: string | null;
+ assignee_name?: string | null;
+}
+
+export interface IssueClosedPayload {
+ issue_id: string;
+ resolution: string;
+}
+
+// Sprint Payloads
+
+export interface SprintStartedPayload {
+ sprint_id: string;
+ sprint_name: string;
+ goal?: string | null;
+ issue_count?: number;
+}
+
+export interface SprintCompletedPayload {
+ sprint_id: string;
+ sprint_name: string;
+ completed_issues?: number;
+ incomplete_issues?: number;
+}
+
+// Approval Payloads
+
+export interface ApprovalRequestedPayload {
+ approval_id: string;
+ approval_type: string;
+ description: string;
+ requested_by?: string | null;
+ timeout_minutes?: number | null;
+}
+
+export interface ApprovalGrantedPayload {
+ approval_id: string;
+ approved_by: string;
+ comments?: string | null;
+}
+
+export interface ApprovalDeniedPayload {
+ approval_id: string;
+ denied_by: string;
+ reason: string;
+}
+
+// Workflow Payloads
+
+export interface WorkflowStartedPayload {
+ workflow_id: string;
+ workflow_type: string;
+ total_steps?: number;
+}
+
+export interface WorkflowStepCompletedPayload {
+ workflow_id: string;
+ step_name: string;
+ step_number: number;
+ total_steps: number;
+ result?: Record;
+}
+
+export interface WorkflowCompletedPayload {
+ workflow_id: string;
+ duration_seconds: number;
+ result?: Record;
+}
+
+export interface WorkflowFailedPayload {
+ workflow_id: string;
+ error_message: string;
+ failed_step?: string | null;
+ recoverable?: boolean;
+}
+
+// ============================================================================
+// Type Guards
+// ============================================================================
+
+/**
+ * Type guard to check if an event is a specific type
+ */
+export function isEventType(
+ event: ProjectEvent,
+ type: T
+): event is ProjectEvent & { type: T } {
+ return event.type === type;
+}
+
+/**
+ * Type guard for agent events
+ */
+export function isAgentEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('agent.');
+}
+
+/**
+ * Type guard for issue events
+ */
+export function isIssueEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('issue.');
+}
+
+/**
+ * Type guard for sprint events
+ */
+export function isSprintEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('sprint.');
+}
+
+/**
+ * Type guard for approval events
+ */
+export function isApprovalEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('approval.');
+}
+
+/**
+ * Type guard for workflow events
+ */
+export function isWorkflowEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('workflow.');
+}
+
+/**
+ * Type guard for project events
+ */
+export function isProjectEvent(event: ProjectEvent): boolean {
+ return event.type.startsWith('project.');
+}
+
+// ============================================================================
+// SSE Connection Types
+// ============================================================================
+
+/**
+ * Connection state for SSE
+ */
+export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
+
+/**
+ * SSE error information
+ */
+export interface SSEError {
+ message: string;
+ code?: string;
+ timestamp: string;
+ retryAttempt?: number;
+}
diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts
new file mode 100644
index 0000000..f8fd031
--- /dev/null
+++ b/frontend/src/lib/types/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Type Definitions
+ *
+ * @module lib/types
+ */
+
+// Event types for SSE
+export {
+ EventType,
+ type ActorType,
+ type ProjectEvent,
+ type EventPayload,
+ type AgentSpawnedPayload,
+ type AgentStatusChangedPayload,
+ type AgentMessagePayload,
+ type AgentTerminatedPayload,
+ type IssueCreatedPayload,
+ type IssueUpdatedPayload,
+ type IssueAssignedPayload,
+ type IssueClosedPayload,
+ type SprintStartedPayload,
+ type SprintCompletedPayload,
+ type ApprovalRequestedPayload,
+ type ApprovalGrantedPayload,
+ type ApprovalDeniedPayload,
+ type WorkflowStartedPayload,
+ type WorkflowStepCompletedPayload,
+ type WorkflowCompletedPayload,
+ type WorkflowFailedPayload,
+ type ConnectionState,
+ type SSEError,
+ isEventType,
+ isAgentEvent,
+ isIssueEvent,
+ isSprintEvent,
+ isApprovalEvent,
+ isWorkflowEvent,
+ isProjectEvent,
+} from './events';
diff --git a/frontend/tests/components/events/ConnectionStatus.test.tsx b/frontend/tests/components/events/ConnectionStatus.test.tsx
new file mode 100644
index 0000000..9615e38
--- /dev/null
+++ b/frontend/tests/components/events/ConnectionStatus.test.tsx
@@ -0,0 +1,192 @@
+/**
+ * Tests for ConnectionStatus Component
+ */
+
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ConnectionStatus } from '@/components/events/ConnectionStatus';
+import type { SSEError } from '@/lib/types/events';
+
+describe('ConnectionStatus', () => {
+ const mockOnReconnect = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('connected state', () => {
+ it('renders connected status', () => {
+ render();
+
+ expect(screen.getByText('Connected')).toBeInTheDocument();
+ expect(screen.getByText('Receiving real-time updates')).toBeInTheDocument();
+ });
+
+ it('does not show reconnect button when connected', () => {
+ render();
+
+ expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
+ });
+
+ it('applies connected styling', () => {
+ const { container } = render();
+
+ expect(container.querySelector('.border-green-200')).toBeInTheDocument();
+ });
+ });
+
+ describe('connecting state', () => {
+ it('renders connecting status', () => {
+ render();
+
+ expect(screen.getByText('Connecting')).toBeInTheDocument();
+ expect(screen.getByText('Establishing connection...')).toBeInTheDocument();
+ });
+
+ it('shows retry count when retrying', () => {
+ render();
+
+ expect(screen.getByText('Retry 3')).toBeInTheDocument();
+ });
+ });
+
+ describe('disconnected state', () => {
+ it('renders disconnected status', () => {
+ render();
+
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+ expect(screen.getByText('Not connected to server')).toBeInTheDocument();
+ });
+
+ it('shows reconnect button when disconnected', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /reconnect/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it('calls onReconnect when button is clicked', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /reconnect/i });
+ fireEvent.click(button);
+
+ expect(mockOnReconnect).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('error state', () => {
+ it('renders error status', () => {
+ render();
+
+ expect(screen.getByText('Connection Error')).toBeInTheDocument();
+ expect(screen.getByText('Failed to connect')).toBeInTheDocument();
+ });
+
+ it('shows reconnect button when in error state', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /reconnect/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it('applies error styling', () => {
+ const { container } = render();
+
+ expect(container.querySelector('.border-destructive')).toBeInTheDocument();
+ });
+ });
+
+ describe('error details', () => {
+ const mockError: SSEError = {
+ message: 'Connection timeout',
+ code: 'TIMEOUT',
+ timestamp: '2024-01-15T10:30:00Z',
+ retryAttempt: 2,
+ };
+
+ it('shows error message when error is provided', () => {
+ render();
+
+ expect(screen.getByText(/Error: Connection timeout/)).toBeInTheDocument();
+ });
+
+ it('shows error code when provided', () => {
+ render();
+
+ expect(screen.getByText(/Code: TIMEOUT/)).toBeInTheDocument();
+ });
+
+ it('hides error details when showErrorDetails is false', () => {
+ render();
+
+ expect(screen.queryByText(/Error: Connection timeout/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('compact mode', () => {
+ it('renders compact version', () => {
+ const { container } = render();
+
+ // Compact mode should not have the full description
+ expect(screen.queryByText('Receiving real-time updates')).not.toBeInTheDocument();
+ // Should still show the label
+ expect(screen.getByText('Connected')).toBeInTheDocument();
+ // Should use smaller container
+ expect(container.querySelector('.rounded-lg')).not.toBeInTheDocument();
+ });
+
+ it('shows compact reconnect button when disconnected', () => {
+ render();
+
+ // Should have a small reconnect button
+ const button = screen.getByRole('button', { name: /reconnect/i });
+ expect(button).toBeInTheDocument();
+ expect(button.className).toContain('h-6');
+ });
+
+ it('shows retry count in compact mode', () => {
+ render();
+
+ expect(screen.getByText(/retry 5/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('showReconnectButton prop', () => {
+ it('hides reconnect button when showReconnectButton is false', () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('has role="status" for screen readers', () => {
+ render();
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('has aria-live="polite" for status updates', () => {
+ render();
+
+ const status = screen.getByRole('status');
+ expect(status).toHaveAttribute('aria-live', 'polite');
+ });
+ });
+
+ describe('className prop', () => {
+ it('applies custom className', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('.custom-class')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/tests/components/events/EventList.test.tsx b/frontend/tests/components/events/EventList.test.tsx
new file mode 100644
index 0000000..313112a
--- /dev/null
+++ b/frontend/tests/components/events/EventList.test.tsx
@@ -0,0 +1,378 @@
+/**
+ * Tests for EventList Component
+ */
+
+import { render, screen, fireEvent } from '@testing-library/react';
+import { EventList } from '@/components/events/EventList';
+import { EventType, type ProjectEvent } from '@/lib/types/events';
+
+/**
+ * Helper to create mock event
+ */
+function createMockEvent(overrides: Partial = {}): ProjectEvent {
+ return {
+ id: `event-${Math.random().toString(36).substr(2, 9)}`,
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date().toISOString(),
+ project_id: 'project-123',
+ actor_id: 'agent-456',
+ actor_type: 'agent',
+ payload: { message: 'Test message' },
+ ...overrides,
+ };
+}
+
+describe('EventList', () => {
+ const mockOnEventClick = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('empty state', () => {
+ it('shows empty message when no events', () => {
+ render();
+
+ expect(screen.getByText('No events yet')).toBeInTheDocument();
+ });
+
+ it('shows custom empty message', () => {
+ render();
+
+ expect(screen.getByText('Waiting for activity...')).toBeInTheDocument();
+ });
+ });
+
+ describe('header', () => {
+ it('shows header by default', () => {
+ render();
+
+ expect(screen.getByText('Activity Feed')).toBeInTheDocument();
+ });
+
+ it('shows custom title', () => {
+ render();
+
+ expect(screen.getByText('Project Events')).toBeInTheDocument();
+ });
+
+ it('hides header when showHeader is false', () => {
+ render();
+
+ expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument();
+ });
+
+ it('shows event count in header', () => {
+ const events = [
+ createMockEvent({ id: 'event-1' }),
+ createMockEvent({ id: 'event-2' }),
+ createMockEvent({ id: 'event-3' }),
+ ];
+
+ render();
+
+ expect(screen.getByText('3 events')).toBeInTheDocument();
+ });
+
+ it('shows singular "event" for one event', () => {
+ const events = [createMockEvent()];
+
+ render();
+
+ expect(screen.getByText('1 event')).toBeInTheDocument();
+ });
+ });
+
+ describe('event display', () => {
+ it('displays agent events correctly', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.AGENT_MESSAGE,
+ payload: { message: 'Processing task...' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Agent Message')).toBeInTheDocument();
+ expect(screen.getByText('Processing task...')).toBeInTheDocument();
+ });
+
+ it('displays issue events correctly', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.ISSUE_CREATED,
+ payload: { title: 'Fix login bug' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Issue Created')).toBeInTheDocument();
+ expect(screen.getByText('Fix login bug')).toBeInTheDocument();
+ });
+
+ it('displays sprint events correctly', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.SPRINT_STARTED,
+ payload: { sprint_name: 'Sprint 1' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Sprint Started')).toBeInTheDocument();
+ expect(screen.getByText(/Sprint "Sprint 1" started/)).toBeInTheDocument();
+ });
+
+ it('displays approval events correctly', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.APPROVAL_REQUESTED,
+ payload: { description: 'Need approval for deployment' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Approval Requested')).toBeInTheDocument();
+ expect(screen.getByText('Need approval for deployment')).toBeInTheDocument();
+ });
+
+ it('displays workflow events correctly', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.WORKFLOW_COMPLETED,
+ payload: { duration_seconds: 120 },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Workflow Completed')).toBeInTheDocument();
+ expect(screen.getByText('Completed in 120s')).toBeInTheDocument();
+ });
+
+ it('displays actor type', () => {
+ const events = [
+ createMockEvent({ actor_type: 'agent' }),
+ createMockEvent({ actor_type: 'user', id: 'event-2' }),
+ createMockEvent({ actor_type: 'system', id: 'event-3' }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Agent')).toBeInTheDocument();
+ expect(screen.getByText('User')).toBeInTheDocument();
+ expect(screen.getByText('System')).toBeInTheDocument();
+ });
+ });
+
+ describe('event sorting', () => {
+ it('sorts events by timestamp, newest first', () => {
+ const events = [
+ createMockEvent({
+ id: 'older',
+ timestamp: '2024-01-01T10:00:00Z',
+ payload: { message: 'Older event' },
+ }),
+ createMockEvent({
+ id: 'newer',
+ timestamp: '2024-01-01T12:00:00Z',
+ payload: { message: 'Newer event' },
+ }),
+ ];
+
+ render();
+
+ const eventTexts = screen.getAllByText(/event$/i);
+ // The "Newer event" should appear first in the DOM
+ const newerIndex = eventTexts.findIndex((el) =>
+ el.closest('[class*="flex gap-3"]')?.textContent?.includes('Newer')
+ );
+ const olderIndex = eventTexts.findIndex((el) =>
+ el.closest('[class*="flex gap-3"]')?.textContent?.includes('Older')
+ );
+
+ // In a sorted list, newer should have lower index
+ expect(newerIndex).toBeLessThan(olderIndex);
+ });
+ });
+
+ describe('payload expansion', () => {
+ it('shows expand button when showPayloads is true', () => {
+ const events = [createMockEvent()];
+
+ render();
+
+ // Should have a chevron button
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('expands payload on click', async () => {
+ const events = [
+ createMockEvent({
+ payload: { custom_field: 'custom_value' },
+ }),
+ ];
+
+ render();
+
+ const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]');
+ expect(eventItem).toBeInTheDocument();
+
+ fireEvent.click(eventItem!);
+
+ // Should show the JSON payload
+ expect(screen.getByText(/"custom_field"/)).toBeInTheDocument();
+ expect(screen.getByText(/"custom_value"/)).toBeInTheDocument();
+ });
+ });
+
+ describe('event click', () => {
+ it('calls onEventClick when event is clicked', () => {
+ const events = [createMockEvent({ id: 'test-event' })];
+
+ render();
+
+ const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]');
+ fireEvent.click(eventItem!);
+
+ expect(mockOnEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'test-event' }));
+ });
+
+ it('makes event item focusable when clickable', () => {
+ const events = [createMockEvent()];
+
+ render();
+
+ const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]');
+ expect(eventItem).toHaveAttribute('tabIndex', '0');
+ });
+
+ it('handles keyboard activation', () => {
+ const events = [createMockEvent({ id: 'keyboard-event' })];
+
+ render();
+
+ const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]');
+ fireEvent.keyDown(eventItem!, { key: 'Enter' });
+
+ expect(mockOnEventClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'keyboard-event' })
+ );
+ });
+
+ it('handles space key activation', () => {
+ const events = [createMockEvent({ id: 'space-event' })];
+
+ render();
+
+ const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]');
+ fireEvent.keyDown(eventItem!, { key: ' ' });
+
+ expect(mockOnEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'space-event' }));
+ });
+ });
+
+ describe('scrolling', () => {
+ it('applies maxHeight style', () => {
+ const events = [createMockEvent()];
+
+ const { container } = render();
+
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ expect(scrollContainer).toHaveStyle({ maxHeight: '300px' });
+ });
+
+ it('accepts string maxHeight', () => {
+ const events = [createMockEvent()];
+
+ const { container } = render();
+
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ expect(scrollContainer).toHaveStyle({ maxHeight: '50vh' });
+ });
+ });
+
+ describe('className prop', () => {
+ it('applies custom className', () => {
+ const { container } = render();
+
+ expect(container.querySelector('.custom-event-list')).toBeInTheDocument();
+ });
+ });
+
+ describe('different event types', () => {
+ it('handles agent spawned event', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.AGENT_SPAWNED,
+ payload: { agent_name: 'Product Owner', role: 'po' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Agent Spawned')).toBeInTheDocument();
+ expect(screen.getByText(/Product Owner spawned as po/)).toBeInTheDocument();
+ });
+
+ it('handles agent terminated event', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.AGENT_TERMINATED,
+ payload: { termination_reason: 'Task completed' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Agent Terminated')).toBeInTheDocument();
+ expect(screen.getByText('Task completed')).toBeInTheDocument();
+ });
+
+ it('handles workflow failed event', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.WORKFLOW_FAILED,
+ payload: { error_message: 'Build failed' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Workflow Failed')).toBeInTheDocument();
+ expect(screen.getByText('Build failed')).toBeInTheDocument();
+ });
+
+ it('handles workflow step completed event', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.WORKFLOW_STEP_COMPLETED,
+ payload: { step_name: 'Build', step_number: 2, total_steps: 5 },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Step Completed')).toBeInTheDocument();
+ expect(screen.getByText(/Step 2\/5: Build/)).toBeInTheDocument();
+ });
+
+ it('handles approval denied event', () => {
+ const events = [
+ createMockEvent({
+ type: EventType.APPROVAL_DENIED,
+ payload: { reason: 'Security review needed' },
+ }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Approval Denied')).toBeInTheDocument();
+ expect(screen.getByText(/Denied: Security review needed/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/tests/lib/hooks/useProjectEvents.test.ts b/frontend/tests/lib/hooks/useProjectEvents.test.ts
new file mode 100644
index 0000000..42d97e6
--- /dev/null
+++ b/frontend/tests/lib/hooks/useProjectEvents.test.ts
@@ -0,0 +1,475 @@
+/**
+ * Tests for useProjectEvents Hook
+ */
+
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useProjectEvents } from '@/lib/hooks/useProjectEvents';
+import { useEventStore } from '@/lib/stores/eventStore';
+import { EventType, type ProjectEvent } from '@/lib/types/events';
+
+// Mock useAuth
+const mockUseAuth = jest.fn();
+
+jest.mock('@/lib/auth/AuthContext', () => ({
+ useAuth: (selector?: (state: unknown) => unknown) => {
+ const state = mockUseAuth();
+ return selector ? selector(state) : state;
+ },
+}));
+
+// Mock config
+jest.mock('@/config/app.config', () => ({
+ __esModule: true,
+ default: {
+ api: {
+ url: 'http://localhost:8000',
+ },
+ debug: {
+ api: false,
+ },
+ },
+}));
+
+// Mock EventSource
+class MockEventSource {
+ static instances: MockEventSource[] = [];
+ url: string;
+ readyState: number = 0;
+ onopen: ((event: Event) => void) | null = null;
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: ((event: Event) => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ MockEventSource.instances.push(this);
+ }
+
+ close() {
+ this.readyState = 2; // CLOSED
+ }
+
+ addEventListener() {}
+ removeEventListener() {}
+
+ // Test helpers
+ simulateOpen() {
+ this.readyState = 1; // OPEN
+ if (this.onopen) {
+ this.onopen(new Event('open'));
+ }
+ }
+
+ simulateMessage(data: string) {
+ if (this.onmessage) {
+ this.onmessage(new MessageEvent('message', { data }));
+ }
+ }
+
+ simulateError() {
+ this.readyState = 2; // CLOSED
+ if (this.onerror) {
+ this.onerror(new Event('error'));
+ }
+ }
+}
+
+// Type augmentation for EventSource
+declare global {
+ interface Window {
+ EventSource: typeof MockEventSource;
+ }
+}
+
+// @ts-expect-error - Mocking global EventSource
+global.EventSource = MockEventSource;
+
+/**
+ * Helper to create mock event
+ */
+function createMockEvent(overrides: Partial = {}): ProjectEvent {
+ return {
+ id: `event-${Math.random().toString(36).substr(2, 9)}`,
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date().toISOString(),
+ project_id: 'project-123',
+ actor_id: 'agent-456',
+ actor_type: 'agent',
+ payload: { message: 'Test message' },
+ ...overrides,
+ };
+}
+
+describe('useProjectEvents', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ MockEventSource.instances = [];
+
+ // Reset event store
+ useEventStore.setState({
+ eventsByProject: {},
+ maxEvents: 100,
+ });
+
+ // Default auth state - authenticated
+ mockUseAuth.mockReturnValue({
+ isAuthenticated: true,
+ accessToken: 'test-access-token',
+ });
+ });
+
+ describe('initialization', () => {
+ it('should start disconnected', () => {
+ const { result } = renderHook(() =>
+ useProjectEvents('project-123', { autoConnect: false })
+ );
+
+ expect(result.current.connectionState).toBe('disconnected');
+ expect(result.current.isConnected).toBe(false);
+ expect(result.current.events).toEqual([]);
+ });
+
+ it('should auto-connect when enabled and authenticated', async () => {
+ renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+ expect(eventSource.url).toContain('/api/v1/projects/project-123/events');
+ expect(eventSource.url).toContain('token=test-access-token');
+ });
+
+ it('should not connect when not authenticated', () => {
+ mockUseAuth.mockReturnValue({
+ isAuthenticated: false,
+ accessToken: null,
+ });
+
+ renderHook(() => useProjectEvents('project-123'));
+
+ expect(MockEventSource.instances).toHaveLength(0);
+ });
+
+ it('should not connect when no project ID', () => {
+ renderHook(() => useProjectEvents(''));
+
+ expect(MockEventSource.instances).toHaveLength(0);
+ });
+ });
+
+ describe('connection state', () => {
+ it('should update to connected on open', async () => {
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ });
+
+ await waitFor(() => {
+ expect(result.current.connectionState).toBe('connected');
+ expect(result.current.isConnected).toBe(true);
+ });
+ });
+
+ it('should update to error on connection error', async () => {
+ const { result } = renderHook(() =>
+ useProjectEvents('project-123', {
+ maxRetryAttempts: 1,
+ })
+ );
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateError();
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+ });
+
+ it('should call onConnectionChange callback', async () => {
+ const onConnectionChange = jest.fn();
+
+ renderHook(() =>
+ useProjectEvents('project-123', {
+ onConnectionChange,
+ })
+ );
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ // Should have been called with 'connecting'
+ expect(onConnectionChange).toHaveBeenCalledWith('connecting');
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ });
+
+ await waitFor(() => {
+ expect(onConnectionChange).toHaveBeenCalledWith('connected');
+ });
+ });
+ });
+
+ describe('event handling', () => {
+ it('should add events to store on message', async () => {
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ });
+
+ const mockEvent = createMockEvent();
+
+ act(() => {
+ eventSource.simulateMessage(JSON.stringify(mockEvent));
+ });
+
+ await waitFor(() => {
+ expect(result.current.events).toHaveLength(1);
+ expect(result.current.events[0].id).toBe(mockEvent.id);
+ });
+ });
+
+ it('should call onEvent callback', async () => {
+ const onEvent = jest.fn();
+
+ renderHook(() =>
+ useProjectEvents('project-123', {
+ onEvent,
+ })
+ );
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ });
+
+ const mockEvent = createMockEvent();
+
+ act(() => {
+ eventSource.simulateMessage(JSON.stringify(mockEvent));
+ });
+
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ id: mockEvent.id }));
+ });
+ });
+
+ it('should ignore invalid JSON', async () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ eventSource.simulateMessage('not valid json');
+ });
+
+ expect(result.current.events).toHaveLength(0);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('should ignore events with missing required fields', async () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ eventSource.simulateMessage(JSON.stringify({ message: 'missing fields' }));
+ });
+
+ expect(result.current.events).toHaveLength(0);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+
+ consoleWarnSpy.mockRestore();
+ });
+ });
+
+ describe('disconnect and reconnect', () => {
+ it('should disconnect when called', async () => {
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const initialInstanceCount = MockEventSource.instances.length;
+ const eventSource = MockEventSource.instances[initialInstanceCount - 1];
+
+ act(() => {
+ eventSource.simulateOpen();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isConnected).toBe(true);
+ });
+
+ act(() => {
+ result.current.disconnect();
+ });
+
+ // After disconnect, the connection state should be disconnected
+ expect(result.current.connectionState).toBe('disconnected');
+ expect(result.current.isConnected).toBe(false);
+ });
+
+ it('should reconnect when called', async () => {
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ act(() => {
+ result.current.disconnect();
+ });
+
+ const instanceCountBeforeReconnect = MockEventSource.instances.length;
+
+ act(() => {
+ result.current.reconnect();
+ });
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(instanceCountBeforeReconnect);
+ });
+ });
+ });
+
+ describe('clearEvents', () => {
+ it('should clear events for the project', async () => {
+ const { result } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateOpen();
+ eventSource.simulateMessage(JSON.stringify(createMockEvent()));
+ });
+
+ await waitFor(() => {
+ expect(result.current.events).toHaveLength(1);
+ });
+
+ act(() => {
+ result.current.clearEvents();
+ });
+
+ expect(result.current.events).toHaveLength(0);
+ });
+ });
+
+ describe('retry behavior', () => {
+ it('should increment retry count on error', async () => {
+ const { result } = renderHook(() =>
+ useProjectEvents('project-123', {
+ maxRetryAttempts: 5,
+ initialRetryDelay: 10, // Short delay for test
+ })
+ );
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateError();
+ });
+
+ await waitFor(() => {
+ expect(result.current.retryCount).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ it('should call onError callback', async () => {
+ const onError = jest.fn();
+
+ renderHook(() =>
+ useProjectEvents('project-123', {
+ onError,
+ maxRetryAttempts: 1,
+ })
+ );
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ act(() => {
+ eventSource.simulateError();
+ });
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('cleanup', () => {
+ it('should close connection on unmount', async () => {
+ const { unmount } = renderHook(() => useProjectEvents('project-123'));
+
+ await waitFor(() => {
+ expect(MockEventSource.instances.length).toBeGreaterThan(0);
+ });
+
+ const eventSource = MockEventSource.instances[0];
+
+ unmount();
+
+ expect(eventSource.readyState).toBe(2); // CLOSED
+ });
+ });
+});
diff --git a/frontend/tests/lib/stores/eventStore.test.ts b/frontend/tests/lib/stores/eventStore.test.ts
new file mode 100644
index 0000000..31ce7fb
--- /dev/null
+++ b/frontend/tests/lib/stores/eventStore.test.ts
@@ -0,0 +1,255 @@
+/**
+ * Tests for Event Store
+ */
+
+import { useEventStore } from '@/lib/stores/eventStore';
+import { EventType, type ProjectEvent } from '@/lib/types/events';
+
+/**
+ * Helper to create mock event
+ */
+function createMockEvent(overrides: Partial = {}): ProjectEvent {
+ return {
+ id: `event-${Math.random().toString(36).substr(2, 9)}`,
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date().toISOString(),
+ project_id: 'project-123',
+ actor_id: 'agent-456',
+ actor_type: 'agent',
+ payload: { message: 'Test message' },
+ ...overrides,
+ };
+}
+
+describe('Event Store', () => {
+ beforeEach(() => {
+ // Reset store state
+ useEventStore.setState({
+ eventsByProject: {},
+ maxEvents: 100,
+ });
+ });
+
+ describe('addEvent', () => {
+ it('should add an event to the store', () => {
+ const event = createMockEvent();
+
+ useEventStore.getState().addEvent(event);
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(1);
+ expect(events[0]).toEqual(event);
+ });
+
+ it('should add events to correct project', () => {
+ const event1 = createMockEvent({ project_id: 'project-1' });
+ const event2 = createMockEvent({ project_id: 'project-2' });
+
+ useEventStore.getState().addEvent(event1);
+ useEventStore.getState().addEvent(event2);
+
+ expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(1);
+ expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
+ });
+
+ it('should skip duplicate event IDs', () => {
+ const event = createMockEvent({ id: 'unique-id' });
+
+ useEventStore.getState().addEvent(event);
+ useEventStore.getState().addEvent(event);
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(1);
+ });
+
+ it('should trim events to maxEvents limit', () => {
+ useEventStore.getState().setMaxEvents(5);
+
+ // Add 10 events
+ for (let i = 0; i < 10; i++) {
+ useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` }));
+ }
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(5);
+ // Should keep the last 5 events
+ expect(events[0].id).toBe('event-5');
+ expect(events[4].id).toBe('event-9');
+ });
+ });
+
+ describe('addEvents', () => {
+ it('should add multiple events at once', () => {
+ const events = [
+ createMockEvent({ id: 'event-1' }),
+ createMockEvent({ id: 'event-2' }),
+ createMockEvent({ id: 'event-3' }),
+ ];
+
+ useEventStore.getState().addEvents(events);
+
+ const storedEvents = useEventStore.getState().getProjectEvents('project-123');
+ expect(storedEvents).toHaveLength(3);
+ });
+
+ it('should handle empty array', () => {
+ useEventStore.getState().addEvents([]);
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(0);
+ });
+
+ it('should filter out duplicate events', () => {
+ const existingEvent = createMockEvent({ id: 'existing-event' });
+ useEventStore.getState().addEvent(existingEvent);
+
+ const newEvents = [
+ createMockEvent({ id: 'existing-event' }), // Duplicate
+ createMockEvent({ id: 'new-event-1' }),
+ createMockEvent({ id: 'new-event-2' }),
+ ];
+
+ useEventStore.getState().addEvents(newEvents);
+
+ const storedEvents = useEventStore.getState().getProjectEvents('project-123');
+ expect(storedEvents).toHaveLength(3);
+ });
+
+ it('should add events to multiple projects', () => {
+ const events = [
+ createMockEvent({ id: 'event-1', project_id: 'project-1' }),
+ createMockEvent({ id: 'event-2', project_id: 'project-2' }),
+ createMockEvent({ id: 'event-3', project_id: 'project-1' }),
+ ];
+
+ useEventStore.getState().addEvents(events);
+
+ expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(2);
+ expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
+ });
+ });
+
+ describe('clearProjectEvents', () => {
+ it('should clear events for a specific project', () => {
+ const event1 = createMockEvent({ project_id: 'project-1' });
+ const event2 = createMockEvent({ project_id: 'project-2' });
+
+ useEventStore.getState().addEvent(event1);
+ useEventStore.getState().addEvent(event2);
+
+ useEventStore.getState().clearProjectEvents('project-1');
+
+ expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0);
+ expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
+ });
+
+ it('should handle clearing non-existent project', () => {
+ expect(() => {
+ useEventStore.getState().clearProjectEvents('non-existent');
+ }).not.toThrow();
+ });
+ });
+
+ describe('clearAllEvents', () => {
+ it('should clear all events from all projects', () => {
+ const event1 = createMockEvent({ project_id: 'project-1' });
+ const event2 = createMockEvent({ project_id: 'project-2' });
+
+ useEventStore.getState().addEvent(event1);
+ useEventStore.getState().addEvent(event2);
+
+ useEventStore.getState().clearAllEvents();
+
+ expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0);
+ expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(0);
+ });
+ });
+
+ describe('getProjectEvents', () => {
+ it('should return empty array for non-existent project', () => {
+ const events = useEventStore.getState().getProjectEvents('non-existent');
+ expect(events).toEqual([]);
+ });
+
+ it('should return events for existing project', () => {
+ const event = createMockEvent();
+ useEventStore.getState().addEvent(event);
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(1);
+ expect(events[0]).toEqual(event);
+ });
+ });
+
+ describe('getFilteredEvents', () => {
+ it('should filter events by type', () => {
+ const agentEvent = createMockEvent({ type: EventType.AGENT_MESSAGE });
+ const issueEvent = createMockEvent({ type: EventType.ISSUE_CREATED });
+ const sprintEvent = createMockEvent({ type: EventType.SPRINT_STARTED });
+
+ useEventStore.getState().addEvents([agentEvent, issueEvent, sprintEvent]);
+
+ const filtered = useEventStore.getState().getFilteredEvents('project-123', [
+ EventType.AGENT_MESSAGE,
+ EventType.ISSUE_CREATED,
+ ]);
+
+ expect(filtered).toHaveLength(2);
+ expect(filtered.map((e) => e.type)).toContain(EventType.AGENT_MESSAGE);
+ expect(filtered.map((e) => e.type)).toContain(EventType.ISSUE_CREATED);
+ });
+
+ it('should return all events when types array is empty', () => {
+ const event1 = createMockEvent({ type: EventType.AGENT_MESSAGE });
+ const event2 = createMockEvent({ type: EventType.ISSUE_CREATED });
+
+ useEventStore.getState().addEvents([event1, event2]);
+
+ const filtered = useEventStore.getState().getFilteredEvents('project-123', []);
+ expect(filtered).toHaveLength(2);
+ });
+
+ it('should return empty array for non-existent project', () => {
+ const filtered = useEventStore.getState().getFilteredEvents('non-existent', [
+ EventType.AGENT_MESSAGE,
+ ]);
+ expect(filtered).toEqual([]);
+ });
+ });
+
+ describe('setMaxEvents', () => {
+ it('should update maxEvents setting', () => {
+ useEventStore.getState().setMaxEvents(50);
+ expect(useEventStore.getState().maxEvents).toBe(50);
+ });
+
+ it('should use default for invalid values', () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ useEventStore.getState().setMaxEvents(0);
+ expect(useEventStore.getState().maxEvents).toBe(100); // Default
+
+ useEventStore.getState().setMaxEvents(-5);
+ expect(useEventStore.getState().maxEvents).toBe(100); // Default
+
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('should trim existing events when reducing maxEvents', () => {
+ // Add 10 events
+ for (let i = 0; i < 10; i++) {
+ useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` }));
+ }
+
+ expect(useEventStore.getState().getProjectEvents('project-123')).toHaveLength(10);
+
+ useEventStore.getState().setMaxEvents(5);
+
+ const events = useEventStore.getState().getProjectEvents('project-123');
+ expect(events).toHaveLength(5);
+ // Should keep the last 5 events
+ expect(events[0].id).toBe('event-5');
+ });
+ });
+});
diff --git a/frontend/tests/lib/types/events.test.ts b/frontend/tests/lib/types/events.test.ts
new file mode 100644
index 0000000..defb8f5
--- /dev/null
+++ b/frontend/tests/lib/types/events.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Tests for Event Types and Type Guards
+ */
+
+import {
+ EventType,
+ type ProjectEvent,
+ isEventType,
+ isAgentEvent,
+ isIssueEvent,
+ isSprintEvent,
+ isApprovalEvent,
+ isWorkflowEvent,
+ isProjectEvent,
+} from '@/lib/types/events';
+
+/**
+ * Helper to create mock event
+ */
+function createMockEvent(overrides: Partial = {}): ProjectEvent {
+ return {
+ id: 'event-123',
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date().toISOString(),
+ project_id: 'project-123',
+ actor_id: 'agent-456',
+ actor_type: 'agent',
+ payload: {},
+ ...overrides,
+ };
+}
+
+describe('Event Types', () => {
+ describe('EventType enum', () => {
+ it('should have correct agent event values', () => {
+ expect(EventType.AGENT_SPAWNED).toBe('agent.spawned');
+ expect(EventType.AGENT_STATUS_CHANGED).toBe('agent.status_changed');
+ expect(EventType.AGENT_MESSAGE).toBe('agent.message');
+ expect(EventType.AGENT_TERMINATED).toBe('agent.terminated');
+ });
+
+ it('should have correct issue event values', () => {
+ expect(EventType.ISSUE_CREATED).toBe('issue.created');
+ expect(EventType.ISSUE_UPDATED).toBe('issue.updated');
+ expect(EventType.ISSUE_ASSIGNED).toBe('issue.assigned');
+ expect(EventType.ISSUE_CLOSED).toBe('issue.closed');
+ });
+
+ it('should have correct sprint event values', () => {
+ expect(EventType.SPRINT_STARTED).toBe('sprint.started');
+ expect(EventType.SPRINT_COMPLETED).toBe('sprint.completed');
+ });
+
+ it('should have correct approval event values', () => {
+ expect(EventType.APPROVAL_REQUESTED).toBe('approval.requested');
+ expect(EventType.APPROVAL_GRANTED).toBe('approval.granted');
+ expect(EventType.APPROVAL_DENIED).toBe('approval.denied');
+ });
+
+ it('should have correct workflow event values', () => {
+ expect(EventType.WORKFLOW_STARTED).toBe('workflow.started');
+ expect(EventType.WORKFLOW_STEP_COMPLETED).toBe('workflow.step_completed');
+ expect(EventType.WORKFLOW_COMPLETED).toBe('workflow.completed');
+ expect(EventType.WORKFLOW_FAILED).toBe('workflow.failed');
+ });
+
+ it('should have correct project event values', () => {
+ expect(EventType.PROJECT_CREATED).toBe('project.created');
+ expect(EventType.PROJECT_UPDATED).toBe('project.updated');
+ expect(EventType.PROJECT_ARCHIVED).toBe('project.archived');
+ });
+ });
+
+ describe('isEventType', () => {
+ it('should return true for matching event type', () => {
+ const event = createMockEvent({ type: EventType.AGENT_MESSAGE });
+ expect(isEventType(event, EventType.AGENT_MESSAGE)).toBe(true);
+ });
+
+ it('should return false for non-matching event type', () => {
+ const event = createMockEvent({ type: EventType.AGENT_MESSAGE });
+ expect(isEventType(event, EventType.ISSUE_CREATED)).toBe(false);
+ });
+ });
+
+ describe('isAgentEvent', () => {
+ it('should return true for agent events', () => {
+ expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_SPAWNED }))).toBe(true);
+ expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(true);
+ expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_STATUS_CHANGED }))).toBe(true);
+ expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_TERMINATED }))).toBe(true);
+ });
+
+ it('should return false for non-agent events', () => {
+ expect(isAgentEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
+ expect(isAgentEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
+ });
+ });
+
+ describe('isIssueEvent', () => {
+ it('should return true for issue events', () => {
+ expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(true);
+ expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_UPDATED }))).toBe(true);
+ expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_ASSIGNED }))).toBe(true);
+ expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CLOSED }))).toBe(true);
+ });
+
+ it('should return false for non-issue events', () => {
+ expect(isIssueEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
+ expect(isIssueEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
+ });
+ });
+
+ describe('isSprintEvent', () => {
+ it('should return true for sprint events', () => {
+ expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(true);
+ expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_COMPLETED }))).toBe(true);
+ });
+
+ it('should return false for non-sprint events', () => {
+ expect(isSprintEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
+ expect(isSprintEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
+ });
+ });
+
+ describe('isApprovalEvent', () => {
+ it('should return true for approval events', () => {
+ expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_REQUESTED }))).toBe(true);
+ expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_GRANTED }))).toBe(true);
+ expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_DENIED }))).toBe(true);
+ });
+
+ it('should return false for non-approval events', () => {
+ expect(isApprovalEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
+ expect(isApprovalEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(false);
+ });
+ });
+
+ describe('isWorkflowEvent', () => {
+ it('should return true for workflow events', () => {
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(true);
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STEP_COMPLETED }))).toBe(
+ true
+ );
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_COMPLETED }))).toBe(true);
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_FAILED }))).toBe(true);
+ });
+
+ it('should return false for non-workflow events', () => {
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
+ expect(isWorkflowEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
+ });
+ });
+
+ describe('isProjectEvent', () => {
+ it('should return true for project events', () => {
+ expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_CREATED }))).toBe(true);
+ expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_UPDATED }))).toBe(true);
+ expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_ARCHIVED }))).toBe(true);
+ });
+
+ it('should return false for non-project events', () => {
+ expect(isProjectEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
+ expect(isProjectEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
+ });
+ });
+});