feat(frontend): Implement client-side SSE handling (#35)
Implements real-time event streaming on the frontend with: - Event types and type guards matching backend EventType enum - Zustand-based event store with per-project buffering - useProjectEvents hook with auto-reconnection and exponential backoff - ConnectionStatus component showing connection state - EventList component with expandable payloads and filtering All 105 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
214
frontend/src/components/events/ConnectionStatus.tsx
Normal file
214
frontend/src/components/events/ConnectionStatus.tsx
Normal file
@@ -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
|
||||
* <ConnectionStatus
|
||||
* state={connectionState}
|
||||
* error={error}
|
||||
* retryCount={retryCount}
|
||||
* onReconnect={reconnect}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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 (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Icon className={cn('h-4 w-4', iconClassName)} aria-hidden="true" />
|
||||
<Badge variant={variant} className="text-xs">
|
||||
{label}
|
||||
{isRetrying && ` (retry ${retryCount})`}
|
||||
</Badge>
|
||||
{showReconnectButton && canReconnect && onReconnect && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReconnect}
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label="Reconnect"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 rounded-lg border p-4',
|
||||
state === 'error' && 'border-destructive bg-destructive/5',
|
||||
state === 'connected' && 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950',
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Header with icon and status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full',
|
||||
state === 'connected' && 'bg-green-100 dark:bg-green-900',
|
||||
state === 'connecting' && 'bg-muted',
|
||||
state === 'disconnected' && 'bg-muted',
|
||||
state === 'error' && 'bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-5 w-5', iconClassName)} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{label}</span>
|
||||
{isRetrying && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Retry {retryCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reconnect button */}
|
||||
{showReconnectButton && canReconnect && onReconnect && (
|
||||
<Button variant="outline" size="sm" onClick={onReconnect} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error details */}
|
||||
{showErrorDetails && error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">Error: {error.message}</p>
|
||||
{error.code && (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Code: {error.code}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(error.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
467
frontend/src/components/events/EventList.tsx
Normal file
467
frontend/src/components/events/EventList.tsx
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 border-b border-border/50 p-3 last:border-b-0',
|
||||
(showPayload || onClick) && 'cursor-pointer hover:bg-muted/50 transition-colors'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full',
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', config.color)} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{actor}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm truncate">{summary}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{timestamp}</span>
|
||||
{showPayload && (
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded payload */}
|
||||
{showPayload && isExpanded && (
|
||||
<div className="mt-2 rounded-md bg-muted/50 p-2">
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
* <EventList
|
||||
* events={events}
|
||||
* maxHeight={500}
|
||||
* showPayloads
|
||||
* onEventClick={(e) => 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 (
|
||||
<Card className={className}>
|
||||
{showHeader && (
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{title}</span>
|
||||
{events.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{events.length} event{events.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={showHeader ? 'pt-0' : ''}>
|
||||
{sortedEvents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<FileText className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }}
|
||||
>
|
||||
<div className="divide-y divide-border/50">
|
||||
{sortedEvents.map((event) => (
|
||||
<EventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
showPayload={showPayloads}
|
||||
onClick={onEventClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/events/index.ts
Normal file
10
frontend/src/components/events/index.ts
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user