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:
2025-12-30 01:34:41 +01:00
parent d6db6af964
commit fcda8f0f96
14 changed files with 3138 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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';