'use client'; import { useEffect, useState, useMemo } from 'react'; import type { BeadIssue } from '../../lib/types'; import type { ActivityEvent } from '../../lib/activity'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead'; interface AgentRosterEntry { name: string; status: AgentStatus; lastSeen: string | null; beadId: string; } interface ActivityPanelProps { issues: BeadIssue[]; collapsed?: boolean; } const AGENT_LABEL = 'gt:agent'; // Determine agent status based on last activity function deriveAgentStatus(lastSeenAt: string | null): AgentStatus { if (!lastSeenAt) return 'dead'; const lastSeen = new Date(lastSeenAt); const now = new Date(); const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60); if (minutesSince < 15) return 'active'; if (minutesSince < 30) return 'stale'; if (minutesSince < 60) return 'stuck'; return 'dead'; } // Get agent name from bead function extractAgentName(issue: BeadIssue): string | null { const agentMatch = issue.title.match(/Agent:\s*(\S+)/i); if (agentMatch) return agentMatch[1]; const agentLabel = issue.labels.find(l => l.startsWith('agent:')); if (agentLabel) return agentLabel.replace('agent:', ''); return null; } // Build agent roster - filter out dead agents unless none are active function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] { const agentIssues = issues.filter(issue => issue.labels.includes(AGENT_LABEL) || issue.labels.some(l => l.startsWith('gt:agent')) ); const roster = agentIssues.map(issue => { const name = extractAgentName(issue) || issue.id; const status = deriveAgentStatus(issue.updated_at); return { name, status, lastSeen: issue.updated_at, beadId: issue.id, }; }).sort((a, b) => { const statusOrder: Record = { active: 0, stale: 1, stuck: 2, dead: 3 }; return statusOrder[a.status] - statusOrder[b.status]; }); // Filter: if there are active agents, show only active + stale (max 5) // If no active, show stale + stuck (max 3) // Dead agents never show unless it's the only thing const activeCount = roster.filter(a => a.status === 'active').length; if (activeCount > 0) { return roster.filter(a => a.status !== 'dead').slice(0, 5); } else { return roster.filter(a => a.status !== 'dead').slice(0, 3); } } // Format relative time function formatRelativeTime(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } // Get event kind icon/color function getEventKindInfo(kind: string): { label: string; color: string } { const events: Record = { created: { label: 'Created', color: 'text-emerald-500' }, closed: { label: 'Closed', color: 'text-amber-500' }, reopened: { label: 'Reopened', color: 'text-blue-500' }, status_changed: { label: 'Status changed', color: 'text-cyan-500' }, priority_changed: { label: 'Priority changed', color: 'text-purple-500' }, assignee_changed: { label: 'Assigned', color: 'text-indigo-500' }, heartbeat: { label: 'Heartbeat', color: 'text-muted-foreground' }, dependency_added: { label: 'Dependency added', color: 'text-orange-500' }, dependency_removed: { label: 'Dependency removed', color: 'text-red-500' }, }; return events[kind] || { label: kind.replace(/_/g, ' '), color: 'text-muted-foreground' }; } function getInitials(name: string): string { return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2); } export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps) { const [activities, setActivities] = useState([]); const [isLoading, setIsLoading] = useState(true); const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]); // Fetch activity history useEffect(() => { async function fetchActivity() { try { const response = await fetch('/api/activity'); if (response.ok) { const data = await response.json(); setActivities(data.slice(0, 50)); // Limit to 50 events } } catch (error) { console.error('[ActivityPanel] Failed to fetch activity:', error); } finally { setIsLoading(false); } } fetchActivity(); }, []); // Subscribe to real-time activity useEffect(() => { const source = new EventSource('/api/events'); const onActivity = (event: MessageEvent) => { try { const data = JSON.parse(event.data); if (data?.event) { setActivities(prev => [data.event, ...prev].slice(0, 50)); } } catch (e) { // Ignore parse errors } }; source.addEventListener('activity', onActivity as EventListener); return () => { source.removeEventListener('activity', onActivity as EventListener); source.close(); }; }, []); const activeAgents = agentRoster.filter(a => a.status === 'active').length; const staleAgents = agentRoster.filter(a => a.status === 'stale').length; if (collapsed) { return (
{/* Collapsed Agent Icons */}
{agentRoster.slice(0, 5).map(agent => (
{getInitials(agent.name)}
))}
{/* Divider */}
{/* Mini Activity Dots (Just visual pulse) */}
{/* Just show a few recent activity dots as a visual "heartbeat" */} {activities.slice(0, 5).map((act) => (
))}
); } return (
{/* AGENT ROSTER SECTION */}

Agents

{activeAgents > 0 && ( {activeAgents} active )} {staleAgents > 0 && ( {staleAgents} stale )}
{agentRoster.length === 0 ? (

No active agents

) : (
{agentRoster.map(agent => (
{getInitials(agent.name)}
{agent.name} {agent.status}
))}
)}
{/* ACTIVITY FEED SECTION */}

Recent Activity

{isLoading ? (
Loading...
) : activities.length === 0 ? (
No recent activity
) : (
{activities.map((activity, index) => { const eventInfo = getEventKindInfo(activity.kind); return (
{eventInfo.label} {activity.beadId}

{activity.beadTitle}

{activity.actor && ( {activity.actor} )} {formatRelativeTime(activity.timestamp)}
{index < activities.length - 1 && ( )}
); })}
)}
); }