feat(frontend): implement activity feed component (#43)
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>
This commit is contained in:
238
frontend/src/app/[locale]/(authenticated)/activity/page.tsx
Normal file
238
frontend/src/app/[locale]/(authenticated)/activity/page.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user