Add shared ActivityFeed component for real-time project activity: - Real-time connection indicator (Live, Connecting, Disconnected, Error) - Time-based event grouping (Today, Yesterday, This Week, Older) - Event type filtering with category checkboxes - Search functionality for filtering events - Expandable event details with raw payload view - Approval request handling (approve/reject buttons) - Loading skeleton and empty state handling - Compact mode for dashboard embedding - WCAG AA accessibility (keyboard navigation, ARIA labels) Components: - ActivityFeed.tsx: Main shared component (900+ lines) - Activity page at /activity for full-page view - Demo events when SSE not connected Testing: - 45 unit tests covering all features - E2E tests for page functionality Closes #43 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
239 lines
7.5 KiB
TypeScript
239 lines
7.5 KiB
TypeScript
/**
|
|
* 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 (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mx-auto max-w-4xl space-y-6">
|
|
{/* Page Header */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Activity Feed</h1>
|
|
<p className="mt-1 text-muted-foreground">Real-time updates from your projects</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{pendingCount > 0 && (
|
|
<Badge variant="destructive" className="gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
{pendingCount} pending
|
|
</Badge>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
|
|
aria-label={notificationsEnabled ? 'Disable notifications' : 'Enable notifications'}
|
|
>
|
|
{notificationsEnabled ? (
|
|
<Bell className="h-4 w-4" />
|
|
) : (
|
|
<BellOff className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={reconnect}
|
|
aria-label="Refresh connection"
|
|
>
|
|
<RefreshCw className={connectionState === 'connecting' ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Demo Mode Banner */}
|
|
{(!isConnected || sseEvents.length === 0) && (
|
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
|
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
<strong>Demo Mode:</strong> Showing sample events. Connect to a real project to see live updates.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity Feed Component */}
|
|
<ActivityFeed
|
|
events={events}
|
|
connectionState={isConnected ? connectionState : 'disconnected'}
|
|
onReconnect={reconnect}
|
|
onApprove={handleApprove}
|
|
onReject={handleReject}
|
|
onEventClick={handleEventClick}
|
|
maxHeight={600}
|
|
showHeader={false}
|
|
enableFiltering
|
|
enableSearch
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|