'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 { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead'; type AgentTone = { cardClass: string; labelClass: string; ringClass: string; glowClass: string; }; type EventTone = { label: string; labelClass: string; dotClass: string; cardClass: string; idClass: string; }; 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')) || issue.labels.includes('agent') ); const roster = agentIssues.map(issue => { const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || 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]; }); // Show all non-dead agents, or at least the most recent ones return roster.filter(a => a.status !== 'dead' || a.lastSeen).slice(0, 10); } // 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' }); } function getAgentTone(status: AgentStatus): AgentTone { const tones: Record = { active: { cardClass: 'bg-[#173126]', labelClass: 'text-[#7CB97A]', ringClass: 'ring-[#7CB97A]/45', glowClass: 'bg-[#7CB97A]/30', }, stale: { cardClass: 'bg-[#322817]', labelClass: 'text-[#D4A574]', ringClass: 'ring-[#D4A574]/45', glowClass: 'bg-[#D4A574]/30', }, stuck: { cardClass: 'bg-[#341a1f]', labelClass: 'text-[#C97A7A]', ringClass: 'ring-[#C97A7A]/45', glowClass: 'bg-[#C97A7A]/30', }, dead: { cardClass: 'bg-[#2b232b]', labelClass: 'text-[#A78A94]', ringClass: 'ring-[#A78A94]/40', glowClass: 'bg-[#A78A94]/25', }, }; return tones[status]; } // reopened=blue, closed=amber, created/opened=green, others semantic function getEventTone(kind: string): EventTone { const normalized = kind.toLowerCase(); const byKind: Record = { created: { label: 'Created', labelClass: 'text-[#7CB97A]', dotClass: 'bg-[#7CB97A]', cardClass: 'bg-[#182f25]', idClass: 'text-[#9ACB98]', }, opened: { label: 'Opened', labelClass: 'text-[#7CB97A]', dotClass: 'bg-[#7CB97A]', cardClass: 'bg-[#182f25]', idClass: 'text-[#9ACB98]', }, closed: { label: 'Closed', labelClass: 'text-[#D4A574]', dotClass: 'bg-[#D4A574]', cardClass: 'bg-[#332716]', idClass: 'text-[#DAB891]', }, reopened: { label: 'Reopened', labelClass: 'text-[#5B95E8]', dotClass: 'bg-[#5B95E8]', cardClass: 'bg-[#1b2b43]', idClass: 'text-[#8DB4EF]', }, status_changed: { label: 'Status changed', labelClass: 'text-[#D4A574]', dotClass: 'bg-[#D4A574]', cardClass: 'bg-[#2f2518]', idClass: 'text-[#DAB891]', }, priority_changed: { label: 'Priority changed', labelClass: 'text-[#D4A574]', dotClass: 'bg-[#D4A574]', cardClass: 'bg-[#2f2518]', idClass: 'text-[#DAB891]', }, assignee_changed: { label: 'Assigned', labelClass: 'text-[#D4A574]', dotClass: 'bg-[#D4A574]', cardClass: 'bg-[#2f2518]', idClass: 'text-[#DAB891]', }, dependency_added: { label: 'Dependency added', labelClass: 'text-[#D4A574]', dotClass: 'bg-[#D4A574]', cardClass: 'bg-[#2f2518]', idClass: 'text-[#DAB891]', }, dependency_removed: { label: 'Dependency removed', labelClass: 'text-[#C97A7A]', dotClass: 'bg-[#C97A7A]', cardClass: 'bg-[#321b21]', idClass: 'text-[#D9A9A9]', }, heartbeat: { label: 'Heartbeat', labelClass: 'text-[#5BA8A0]', dotClass: 'bg-[#5BA8A0]', cardClass: 'bg-[#173034]', idClass: 'text-[#8BC9C1]', }, commented: { label: 'Commented', labelClass: 'text-[#5BA8A0]', dotClass: 'bg-[#5BA8A0]', cardClass: 'bg-[#173034]', idClass: 'text-[#8BC9C1]', }, comment_added: { label: 'Commented', labelClass: 'text-[#5BA8A0]', dotClass: 'bg-[#5BA8A0]', cardClass: 'bg-[#173034]', idClass: 'text-[#8BC9C1]', }, }; return ( byKind[normalized] || { label: normalized.replace(/_/g, ' '), labelClass: 'text-[#5BA8A0]', dotClass: 'bg-[#5BA8A0]', cardClass: 'bg-[#173034]', idClass: 'text-[#8BC9C1]', } ); } 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; if (collapsed) { return (
{/* Collapsed Agent Icons with ZFC Rings */}
{agentRoster.slice(0, 6).map(agent => (
{getInitials(agent.name)}
))}
{/* Activity Pulses */}
{activities.slice(0, 8).map((act) => (
))}
); } return (
{/* AGENT ROSTER SECTION */}

Live Agents

{activeAgents} ONLINE
{agentRoster.length === 0 ? (

No agents broadcasting

) : (
{agentRoster.map(agent => (
{getInitials(agent.name)}
{agent.name}
{agent.status} {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
))}
)}
{/* ACTIVITY FEED SECTION */}

Telemetry Stream

{isLoading ? (
SYNCING...
) : activities.length === 0 ? (

VOID_STREAM_NULL

) : (
{activities.map((activity) => { const eventTone = getEventTone(activity.kind); return (
{eventTone.label} {formatRelativeTime(activity.timestamp)}

{activity.beadTitle}

{activity.beadId} {activity.actor && (
{activity.actor[0].toUpperCase()}
{activity.actor}
)}
); })}
)}
); }