forked from cardosofelipe/fast-next-template
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>
468 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|