diff --git a/frontend/e2e/activity-feed.spec.ts b/frontend/e2e/activity-feed.spec.ts
new file mode 100644
index 0000000..c2dddd4
--- /dev/null
+++ b/frontend/e2e/activity-feed.spec.ts
@@ -0,0 +1,202 @@
+/**
+ * E2E Tests for Activity Feed Page
+ *
+ * Tests the real-time activity feed functionality:
+ * - Page navigation and layout
+ * - Event display and filtering
+ * - Search functionality
+ * - Approval handling
+ * - Time-based grouping
+ */
+
+import { test, expect } from '@playwright/test';
+
+test.describe('Activity Feed Page', () => {
+ test.beforeEach(async ({ page }) => {
+ // Navigate to activity page (authenticated route)
+ // The page uses demo mode when SSE is not connected
+ await page.goto('/en/activity');
+ });
+
+ test('displays page header with title', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'Activity Feed', level: 1 })).toBeVisible();
+ await expect(page.getByText('Real-time updates from your projects')).toBeVisible();
+ });
+
+ test('shows demo mode banner when not connected to SSE', async ({ page }) => {
+ // Demo mode banner should be visible
+ await expect(page.getByText(/Demo Mode/)).toBeVisible();
+ await expect(page.getByText(/Showing sample events/)).toBeVisible();
+ });
+
+ test('displays activity feed component', async ({ page }) => {
+ await expect(page.getByTestId('activity-feed')).toBeVisible();
+ });
+
+ test('displays demo events in time groups', async ({ page }) => {
+ // Should have time-based grouping
+ await expect(page.getByTestId('event-group-today')).toBeVisible();
+ });
+
+ test('search functionality filters events', async ({ page }) => {
+ const searchInput = page.getByTestId('search-input');
+ await expect(searchInput).toBeVisible();
+
+ // Search for a specific term
+ await searchInput.fill('JWT');
+
+ // Should find the JWT-related event
+ await expect(page.getByText(/Completed JWT/)).toBeVisible();
+
+ // Clear search
+ await searchInput.clear();
+ });
+
+ test('filter panel toggles visibility', async ({ page }) => {
+ const filterToggle = page.getByTestId('filter-toggle');
+ await expect(filterToggle).toBeVisible();
+
+ // Click to open filter panel
+ await filterToggle.click();
+ await expect(page.getByTestId('filter-panel')).toBeVisible();
+
+ // Click to close filter panel
+ await filterToggle.click();
+ await expect(page.getByTestId('filter-panel')).not.toBeVisible();
+ });
+
+ test('filter by event category', async ({ page }) => {
+ // Open filter panel
+ await page.getByTestId('filter-toggle').click();
+
+ // Select Agent Actions filter
+ await page.getByLabel(/Agent Actions/).click();
+
+ // Should filter events
+ // Agent events should still be visible
+ await expect(page.getByText(/Completed JWT/)).toBeVisible();
+ });
+
+ test('pending approvals filter', async ({ page }) => {
+ // Open filter panel
+ await page.getByTestId('filter-toggle').click();
+
+ // Select pending only filter
+ await page.getByLabel(/Show only pending approvals/).click();
+
+ // Only approval events should be visible
+ await expect(page.getByText(/Approval required/)).toBeVisible();
+ await expect(page.getByText(/Completed JWT/)).not.toBeVisible();
+ });
+
+ test('event item can be expanded', async ({ page }) => {
+ // Find and click an event item
+ const firstEvent = page.getByTestId(/event-item-/).first();
+ await firstEvent.click();
+
+ // Event details should be visible
+ await expect(page.getByTestId('event-details')).toBeVisible();
+
+ // Should show raw payload option
+ await expect(page.getByText('View raw payload')).toBeVisible();
+ });
+
+ test('approval actions are visible for pending approvals', async ({ page }) => {
+ // Find approval event
+ const approvalEvent = page.locator('[data-testid^="event-item-"]', {
+ has: page.getByText('Action Required'),
+ }).first();
+
+ // Approval buttons should be visible
+ await expect(approvalEvent.getByTestId('approve-button')).toBeVisible();
+ await expect(approvalEvent.getByTestId('reject-button')).toBeVisible();
+ });
+
+ test('notification toggle works', async ({ page }) => {
+ const bellButton = page.getByLabel(/notifications/i);
+ await expect(bellButton).toBeVisible();
+
+ // Click to toggle notifications
+ await bellButton.click();
+
+ // Bell icon should change (hard to verify icon change in E2E, but click should work)
+ await expect(bellButton).toBeEnabled();
+ });
+
+ test('refresh button triggers reconnection', async ({ page }) => {
+ const refreshButton = page.getByLabel('Refresh connection');
+ await expect(refreshButton).toBeVisible();
+
+ // Click refresh
+ await refreshButton.click();
+
+ // Button should still be visible and enabled
+ await expect(refreshButton).toBeEnabled();
+ });
+
+ test('pending count badge shows correct count', async ({ page }) => {
+ // Should show pending count
+ await expect(page.getByText(/pending$/)).toBeVisible();
+ });
+
+ test('keyboard navigation works for events', async ({ page }) => {
+ // Tab to first event
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+
+ // Find focused element and press Enter
+ const focusedElement = page.locator(':focus');
+
+ // If it's an event item, pressing Enter should expand it
+ const testId = await focusedElement.getAttribute('data-testid');
+ if (testId?.startsWith('event-item-')) {
+ await page.keyboard.press('Enter');
+ await expect(page.getByTestId('event-details')).toBeVisible();
+ }
+ });
+
+ test('clear filters button works', async ({ page }) => {
+ // Open filter panel and set some filters
+ await page.getByTestId('filter-toggle').click();
+ await page.getByLabel(/Agent Actions/).click();
+
+ // Click clear filters
+ await page.getByText('Clear Filters').click();
+
+ // All events should be visible again
+ await expect(page.getByText(/Completed JWT/)).toBeVisible();
+ await expect(page.getByText(/Approval required/)).toBeVisible();
+ });
+
+ test('responsive layout adapts to viewport', async ({ page }) => {
+ // Test mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 });
+
+ // Page should still be functional
+ await expect(page.getByTestId('activity-feed')).toBeVisible();
+ await expect(page.getByTestId('search-input')).toBeVisible();
+
+ // Reset viewport
+ await page.setViewportSize({ width: 1280, height: 720 });
+ });
+});
+
+test.describe('Activity Feed - Authenticated Routes', () => {
+ test('redirects to login when not authenticated', async ({ page }) => {
+ // Clear any existing auth state
+ await page.context().clearCookies();
+
+ // Try to access activity page
+ await page.goto('/en/activity');
+
+ // Should redirect to login (AuthGuard behavior)
+ // The exact behavior depends on AuthGuard implementation
+ await page.waitForLoadState('networkidle');
+
+ // Either on activity page (if demo mode) or redirected to login
+ const url = page.url();
+ expect(url.includes('/activity') || url.includes('/login')).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/[locale]/(authenticated)/activity/page.tsx b/frontend/src/app/[locale]/(authenticated)/activity/page.tsx
new file mode 100644
index 0000000..1a918ce
--- /dev/null
+++ b/frontend/src/app/[locale]/(authenticated)/activity/page.tsx
@@ -0,0 +1,238 @@
+/**
+ * Activity Feed Page
+ *
+ * Full-page view of real-time project activity with:
+ * - Real-time SSE connection
+ * - Event filtering and search
+ * - Time-based grouping
+ * - Approval handling
+ */
+
+'use client';
+
+import { useState, useCallback } from 'react';
+import { useProjectEvents } from '@/lib/hooks/useProjectEvents';
+import { ActivityFeed } from '@/components/activity';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { RefreshCw, Bell, BellOff, AlertTriangle } from 'lucide-react';
+import { EventType, type ProjectEvent } from '@/lib/types/events';
+import { toast } from 'sonner';
+
+// For demo purposes, use a placeholder project ID
+// In a real app, this would come from route params or user context
+const DEMO_PROJECT_ID = 'demo-project-001';
+
+// Demo events for when SSE is not connected
+const DEMO_EVENTS: ProjectEvent[] = [
+ {
+ id: 'demo-001',
+ type: EventType.APPROVAL_REQUESTED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: 'agent-001',
+ actor_type: 'agent',
+ payload: {
+ approval_id: 'apr-001',
+ approval_type: 'architecture_decision',
+ description: 'Approval required for API design document for the checkout flow.',
+ requested_by: 'Architect',
+ timeout_minutes: 60,
+ },
+ },
+ {
+ id: 'demo-002',
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: 'agent-002',
+ actor_type: 'agent',
+ payload: {
+ agent_instance_id: 'agent-002',
+ message: 'Completed JWT token generation and validation. Moving on to session management.',
+ message_type: 'info',
+ metadata: { progress: 65 },
+ },
+ },
+ {
+ id: 'demo-003',
+ type: EventType.AGENT_STATUS_CHANGED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: 'agent-003',
+ actor_type: 'agent',
+ payload: {
+ agent_instance_id: 'agent-003',
+ previous_status: 'idle',
+ new_status: 'active',
+ reason: 'Started working on product catalog component',
+ },
+ },
+ {
+ id: 'demo-004',
+ type: EventType.ISSUE_UPDATED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: 'agent-002',
+ actor_type: 'agent',
+ payload: {
+ issue_id: 'issue-038',
+ changes: { status: { from: 'in_progress', to: 'in_review' } },
+ },
+ },
+ {
+ id: 'demo-005',
+ type: EventType.SPRINT_STARTED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: null,
+ actor_type: 'system',
+ payload: {
+ sprint_id: 'sprint-003',
+ sprint_name: 'Sprint 3 - Authentication',
+ goal: 'Complete user authentication module',
+ issue_count: 8,
+ },
+ },
+ {
+ id: 'demo-006',
+ type: EventType.WORKFLOW_COMPLETED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: null,
+ actor_type: 'system',
+ payload: {
+ workflow_id: 'wf-001',
+ duration_seconds: 3600,
+ result: { issues_completed: 5, code_coverage: 92 },
+ },
+ },
+ {
+ id: 'demo-007',
+ type: EventType.ISSUE_CREATED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
+ project_id: DEMO_PROJECT_ID,
+ actor_id: 'agent-001',
+ actor_type: 'agent',
+ payload: {
+ issue_id: 'issue-050',
+ title: 'Add rate limiting to API endpoints',
+ priority: 'medium',
+ labels: ['security', 'api'],
+ },
+ },
+];
+
+export default function ActivityFeedPage() {
+ const [notificationsEnabled, setNotificationsEnabled] = useState(true);
+
+ // SSE hook for real-time events
+ const {
+ events: sseEvents,
+ connectionState,
+ reconnect,
+ isConnected,
+ } = useProjectEvents(DEMO_PROJECT_ID, {
+ autoConnect: true,
+ onEvent: (event) => {
+ // Show notification for new events if enabled
+ if (notificationsEnabled && event.type === EventType.APPROVAL_REQUESTED) {
+ toast.info('New approval request', {
+ description: 'An agent is requesting your approval.',
+ });
+ }
+ },
+ });
+
+ // Use demo events when not connected or no events received
+ const events = isConnected && sseEvents.length > 0 ? sseEvents : DEMO_EVENTS;
+
+ // Approval handlers
+ const handleApprove = useCallback((event: ProjectEvent) => {
+ // In a real app, this would call an API to approve the request
+ toast.success('Approval granted', {
+ description: `Approved request ${event.id}`,
+ });
+ }, []);
+
+ const handleReject = useCallback((event: ProjectEvent) => {
+ // In a real app, this would call an API to reject the request
+ toast.info('Approval rejected', {
+ description: `Rejected request ${event.id}`,
+ });
+ }, []);
+
+ const handleEventClick = useCallback((event: ProjectEvent) => {
+ // In a real app, this might navigate to a detail view
+ if (process.env.NODE_ENV === 'development') {
+ console.log('[ActivityFeedPage] Event clicked:', event);
+ }
+ }, []);
+
+ const pendingCount = events.filter((e) => e.type === EventType.APPROVAL_REQUESTED).length;
+
+ return (
+
+
+ {/* Page Header */}
+
+
+
Activity Feed
+
Real-time updates from your projects
+
+
+ {pendingCount > 0 && (
+
+
+ {pendingCount} pending
+
+ )}
+
+
+
+
+
+ {/* Demo Mode Banner */}
+ {(!isConnected || sseEvents.length === 0) && (
+
+
+ Demo Mode: Showing sample events. Connect to a real project to see live updates.
+
+
+ )}
+
+ {/* Activity Feed Component */}
+
+
+
+ );
+}
diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx
new file mode 100644
index 0000000..0bdafac
--- /dev/null
+++ b/frontend/src/components/activity/ActivityFeed.tsx
@@ -0,0 +1,928 @@
+/**
+ * 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;
+
+ // 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' };
+ }
+ if (isIssueEvent(event)) {
+ return { icon: FileText, label: event.type, color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900' };
+ }
+ if (isSprintEvent(event)) {
+ return { icon: PlayCircle, label: event.type, color: 'text-indigo-500', bgColor: 'bg-indigo-100 dark:bg-indigo-900' };
+ }
+ if (isApprovalEvent(event)) {
+ return { icon: AlertTriangle, label: event.type, color: 'text-orange-500', bgColor: 'bg-orange-100 dark:bg-orange-900' };
+ }
+ if (isWorkflowEvent(event)) {
+ return { icon: Workflow, label: event.type, color: 'text-cyan-500', bgColor: 'bg-cyan-100 dark:bg-cyan-900' };
+ }
+ if (isProjectEvent(event)) {
+ return { icon: Folder, label: event.type, color: 'text-teal-500', bgColor: 'bg-teal-100 dark:bg-teal-900' };
+ }
+
+ 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) {
+ 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 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';
+ 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 (
+
+
+ {config.label}
+ {canReconnect && onReconnect && (
+
+ )}
+
+ );
+}
+
+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 && (
+
+
+ Issue #{issueId}
+
+ )}
+ {pullRequest && (
+
+
+ PR #{String(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 (
+
+
+
No activity found
+
+ {hasFilters
+ ? 'Try adjusting your search or filters'
+ : 'Activity will appear here as agents work on your projects'}
+
+
+ );
+}
+
+// ============================================================================
+// 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}
+ />
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/activity/index.ts b/frontend/src/components/activity/index.ts
new file mode 100644
index 0000000..c90cba6
--- /dev/null
+++ b/frontend/src/components/activity/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Activity Components
+ *
+ * Shared components for displaying real-time activity feeds across
+ * dashboards and dedicated activity pages.
+ */
+
+export { ActivityFeed } from './ActivityFeed';
+export type { ActivityFeedProps, EventTypeFilter } from './ActivityFeed';
diff --git a/frontend/tests/components/activity/ActivityFeed.test.tsx b/frontend/tests/components/activity/ActivityFeed.test.tsx
new file mode 100644
index 0000000..3ab4d26
--- /dev/null
+++ b/frontend/tests/components/activity/ActivityFeed.test.tsx
@@ -0,0 +1,504 @@
+/**
+ * Tests for ActivityFeed Component
+ *
+ * Tests cover:
+ * - Rendering with events
+ * - Connection state indicator
+ * - Search functionality
+ * - Filter functionality
+ * - Event expansion
+ * - Approval actions
+ * - Time-based grouping
+ * - Loading state
+ * - Empty state
+ */
+
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ActivityFeed } from '@/components/activity/ActivityFeed';
+import { EventType, type ProjectEvent } from '@/lib/types/events';
+
+// ============================================================================
+// Test Data
+// ============================================================================
+
+const createMockEvent = (overrides: Partial = {}): ProjectEvent => ({
+ id: `event-${Math.random().toString(36).substr(2, 9)}`,
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date().toISOString(),
+ project_id: 'project-001',
+ actor_id: 'agent-001',
+ actor_type: 'agent',
+ payload: {
+ agent_instance_id: 'agent-001',
+ message: 'Test message',
+ message_type: 'info',
+ },
+ ...overrides,
+});
+
+const mockEvents: ProjectEvent[] = [
+ // Today's events
+ createMockEvent({
+ id: 'event-001',
+ type: EventType.APPROVAL_REQUESTED,
+ timestamp: new Date().toISOString(),
+ payload: {
+ approval_id: 'apr-001',
+ approval_type: 'architecture_decision',
+ description: 'Approval required for API design',
+ requested_by: 'Architect',
+ },
+ }),
+ createMockEvent({
+ id: 'event-002',
+ type: EventType.AGENT_MESSAGE,
+ timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
+ payload: {
+ agent_instance_id: 'agent-002',
+ message: 'Completed JWT implementation',
+ message_type: 'info',
+ },
+ }),
+ // Yesterday's event
+ createMockEvent({
+ id: 'event-003',
+ type: EventType.ISSUE_CREATED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
+ payload: {
+ issue_id: 'issue-001',
+ title: 'Add rate limiting',
+ priority: 'medium',
+ },
+ }),
+ // This week's event
+ createMockEvent({
+ id: 'event-004',
+ type: EventType.SPRINT_STARTED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
+ payload: {
+ sprint_id: 'sprint-001',
+ sprint_name: 'Sprint 1',
+ goal: 'Complete auth module',
+ },
+ }),
+ // Older event
+ createMockEvent({
+ id: 'event-005',
+ type: EventType.WORKFLOW_COMPLETED,
+ timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10).toISOString(),
+ payload: {
+ workflow_id: 'wf-001',
+ duration_seconds: 3600,
+ },
+ }),
+];
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('ActivityFeed', () => {
+ const defaultProps = {
+ events: mockEvents,
+ connectionState: 'connected' as const,
+ };
+
+ describe('Rendering', () => {
+ it('renders the activity feed with test id', () => {
+ render();
+ expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
+ });
+
+ it('renders the header with title', () => {
+ render();
+ expect(screen.getByText('Activity Feed')).toBeInTheDocument();
+ });
+
+ it('renders custom title when provided', () => {
+ render();
+ expect(screen.getByText('Project Activity')).toBeInTheDocument();
+ });
+
+ it('hides header when showHeader is false', () => {
+ render();
+ expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument();
+ });
+
+ it('renders events', () => {
+ render();
+ expect(screen.getByTestId('event-item-event-001')).toBeInTheDocument();
+ expect(screen.getByTestId('event-item-event-002')).toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ render();
+ expect(screen.getByTestId('activity-feed')).toHaveClass('custom-class');
+ });
+ });
+
+ describe('Connection State', () => {
+ it('renders connection indicator', () => {
+ render();
+ expect(screen.getByTestId('connection-indicator')).toBeInTheDocument();
+ });
+
+ it('shows "Live" when connected', () => {
+ render();
+ expect(screen.getByText('Live')).toBeInTheDocument();
+ });
+
+ it('shows "Connecting..." when connecting', () => {
+ render();
+ expect(screen.getByText('Connecting...')).toBeInTheDocument();
+ });
+
+ it('shows "Disconnected" when disconnected', () => {
+ render();
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+ });
+
+ it('shows "Error" when error state', () => {
+ render();
+ expect(screen.getByText('Error')).toBeInTheDocument();
+ });
+
+ it('shows reconnect button when disconnected', () => {
+ const onReconnect = jest.fn();
+ render(
+
+ );
+ const reconnectButton = screen.getByLabelText('Reconnect');
+ expect(reconnectButton).toBeInTheDocument();
+ });
+
+ it('calls onReconnect when reconnect button clicked', async () => {
+ const user = userEvent.setup();
+ const onReconnect = jest.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByLabelText('Reconnect'));
+ expect(onReconnect).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('renders search input when enableSearch is true', () => {
+ render();
+ expect(screen.getByTestId('search-input')).toBeInTheDocument();
+ });
+
+ it('hides search input when enableSearch is false', () => {
+ render();
+ expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
+ });
+
+ it('filters events based on search query', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const searchInput = screen.getByTestId('search-input');
+ await user.type(searchInput, 'JWT');
+
+ // Event with JWT in message should be visible
+ expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument();
+ // Other events should be filtered out
+ expect(screen.queryByText(/Approval required for API design/)).not.toBeInTheDocument();
+ });
+
+ it('shows empty state when search finds no results', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const searchInput = screen.getByTestId('search-input');
+ await user.type(searchInput, 'nonexistent query xyz');
+
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ expect(screen.getByText('No activity found')).toBeInTheDocument();
+ });
+ });
+
+ describe('Filter Functionality', () => {
+ it('renders filter toggle when enableFiltering is true', () => {
+ render();
+ expect(screen.getByTestId('filter-toggle')).toBeInTheDocument();
+ });
+
+ it('hides filter toggle when enableFiltering is false', () => {
+ render();
+ expect(screen.queryByTestId('filter-toggle')).not.toBeInTheDocument();
+ });
+
+ it('shows filter panel when filter toggle is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('filter-toggle'));
+ expect(screen.getByTestId('filter-panel')).toBeInTheDocument();
+ });
+
+ it('filters events by category when filter is selected', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Open filter panel
+ await user.click(screen.getByTestId('filter-toggle'));
+
+ // Select Issues category
+ const issuesCheckbox = screen.getByLabelText(/Issues/);
+ await user.click(issuesCheckbox);
+
+ // Only issue events should be visible
+ expect(screen.getByText(/Add rate limiting/)).toBeInTheDocument();
+ // Agent events should be filtered out
+ expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument();
+ });
+
+ it('shows pending only when filter is selected', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Open filter panel
+ await user.click(screen.getByTestId('filter-toggle'));
+
+ // Select pending only
+ const pendingCheckbox = screen.getByLabelText(/Show only pending approvals/);
+ await user.click(pendingCheckbox);
+
+ // Only approval requested events should be visible
+ expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument();
+ // Other events should be filtered out
+ expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument();
+ });
+
+ it('clears filters when Clear Filters is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Add search query
+ await user.type(screen.getByTestId('search-input'), 'JWT');
+
+ // Open filter panel and select a filter
+ await user.click(screen.getByTestId('filter-toggle'));
+ await user.click(screen.getByLabelText(/Issues/));
+
+ // Clear filters
+ await user.click(screen.getByText('Clear Filters'));
+
+ // All events should be visible again
+ expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument();
+ expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Event Expansion', () => {
+ it('expands event details when clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+ await user.click(eventItem);
+
+ expect(screen.getByTestId('event-details')).toBeInTheDocument();
+ });
+
+ it('collapses event details when clicked again', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+
+ // Expand
+ await user.click(eventItem);
+ expect(screen.getByTestId('event-details')).toBeInTheDocument();
+
+ // Collapse
+ await user.click(eventItem);
+ expect(screen.queryByTestId('event-details')).not.toBeInTheDocument();
+ });
+
+ it('shows raw payload in expanded details', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+ await user.click(eventItem);
+
+ // Check for payload content
+ expect(screen.getByText(/View raw payload/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Approval Actions', () => {
+ it('shows approve and reject buttons for pending approvals', () => {
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+ expect(within(eventItem).getByTestId('approve-button')).toBeInTheDocument();
+ expect(within(eventItem).getByTestId('reject-button')).toBeInTheDocument();
+ });
+
+ it('does not show action buttons for non-approval events', () => {
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-002');
+ expect(within(eventItem).queryByTestId('approve-button')).not.toBeInTheDocument();
+ });
+
+ it('calls onApprove when approve button clicked', async () => {
+ const user = userEvent.setup();
+ const onApprove = jest.fn();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+ await user.click(within(eventItem).getByTestId('approve-button'));
+
+ expect(onApprove).toHaveBeenCalledTimes(1);
+ expect(onApprove).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'event-001' })
+ );
+ });
+
+ it('calls onReject when reject button clicked', async () => {
+ const user = userEvent.setup();
+ const onReject = jest.fn();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+ await user.click(within(eventItem).getByTestId('reject-button'));
+
+ expect(onReject).toHaveBeenCalledTimes(1);
+ expect(onReject).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'event-001' })
+ );
+ });
+
+ it('shows pending count badge', () => {
+ render();
+ expect(screen.getByText('1 pending')).toBeInTheDocument();
+ });
+ });
+
+ describe('Time-Based Grouping', () => {
+ it('groups events by time period', () => {
+ render();
+
+ // Check for time period headers
+ expect(screen.getByTestId('event-group-today')).toBeInTheDocument();
+ });
+
+ it('shows event count in group header', () => {
+ render();
+
+ const todayGroup = screen.getByTestId('event-group-today');
+ // Today has 2 events in our mock data
+ expect(within(todayGroup).getByText('2')).toBeInTheDocument();
+ });
+ });
+
+ describe('Loading State', () => {
+ it('shows loading skeleton when isLoading is true', () => {
+ render();
+ expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
+ });
+
+ it('hides events when loading', () => {
+ render();
+ expect(screen.queryByTestId('event-item-event-001')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Empty State', () => {
+ it('shows empty state when no events', () => {
+ render();
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+ });
+
+ it('shows appropriate message when no events and no filters', () => {
+ render();
+ expect(screen.getByText(/Activity will appear here/)).toBeInTheDocument();
+ });
+
+ it('shows appropriate message when filtered to empty', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByTestId('search-input'), 'nonexistent');
+
+ expect(screen.getByText(/Try adjusting your search or filters/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Event Click Handler', () => {
+ it('calls onEventClick when event is clicked', async () => {
+ const user = userEvent.setup();
+ const onEventClick = jest.fn();
+ render();
+
+ await user.click(screen.getByTestId('event-item-event-001'));
+
+ expect(onEventClick).toHaveBeenCalledTimes(1);
+ expect(onEventClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'event-001' })
+ );
+ });
+ });
+
+ describe('Compact Mode', () => {
+ it('applies compact styling when compact is true', () => {
+ render();
+
+ // Check for compact-specific styling (p-2 instead of p-4)
+ const eventItem = screen.getByTestId('event-item-event-001');
+ // The event item should have compact padding
+ expect(eventItem).toHaveClass('p-2');
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('has proper ARIA labels for interactive elements', () => {
+ render();
+
+ expect(screen.getByLabelText('Reconnect')).toBeInTheDocument();
+ });
+
+ it('event items are keyboard accessible', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const eventItem = screen.getByTestId('event-item-event-001');
+
+ // Focus and activate with keyboard
+ eventItem.focus();
+ await user.keyboard('{Enter}');
+
+ expect(screen.getByTestId('event-details')).toBeInTheDocument();
+ });
+
+ it('renders semantic HTML structure', () => {
+ render();
+
+ // Check for proper heading hierarchy
+ const heading = screen.getByText('Today');
+ expect(heading.tagName).toBe('H3');
+ });
+ });
+
+ describe('Max Height', () => {
+ it('applies max height styling', () => {
+ const { container } = render();
+
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ expect(scrollContainer).toHaveStyle({ maxHeight: '500px' });
+ });
+
+ it('handles string max height', () => {
+ const { container } = render();
+
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ expect(scrollContainer).toHaveStyle({ maxHeight: 'auto' });
+ });
+ });
+});