Files
syndarix/frontend/src/components/events/EventList.tsx
Felipe Cardoso fcda8f0f96 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>
2025-12-30 01:34:41 +01:00

468 lines
14 KiB
TypeScript

/**
* 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>
);
}