'use client' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { useSmartPoll } from '@/lib/use-smart-poll' import { useMissionControl, type LogEntry, type Session } from '@/store' import type { AggregateEvent } from '@/app/api/sessions/transcript/aggregate/route' const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase() // โ”€โ”€ Feed categories (mirrors OpenClaw TUI FeedCategory) โ”€โ”€ type FeedCategory = 'chat' | 'tools' | 'trace' | 'system' | 'safety' type FeedFilter = 'all' | FeedCategory interface FeedEvent { id: string ts: number category: FeedCategory source: string message: string level?: 'info' | 'warn' | 'error' | 'debug' data?: any } // Agent identity: color + emoji (matches openclaw.json) const AGENT_IDENTITY: Record = { [COORDINATOR_AGENT]: { color: '#a78bfa', emoji: '๐Ÿงญ', label: 'Coordinator' }, builder: { color: '#60a5fa', emoji: '๐Ÿ› ๏ธ', label: 'Builder' }, research: { color: '#4ade80', emoji: '๐Ÿ”ฌ', label: 'Research' }, content: { color: '#818cf8', emoji: 'โœ๏ธ', label: 'Content' }, ops: { color: '#fb923c', emoji: 'โšก', label: 'Ops' }, quant: { color: '#facc15', emoji: '๐Ÿ“ˆ', label: 'Quant' }, aegis: { color: '#f87171', emoji: '๐Ÿงช', label: 'Aegis' }, reviewer: { color: '#2dd4bf', emoji: '๐Ÿงช', label: 'Reviewer' }, design: { color: '#f472b6', emoji: '๐ŸŽจ', label: 'Design' }, seo: { color: '#22d3ee', emoji: '๐Ÿ”Ž', label: 'SEO' }, security: { color: '#fb7185', emoji: '๐Ÿ›ก๏ธ', label: 'Security' }, ai: { color: '#8b5cf6', emoji: '๐Ÿค–', label: 'AI' }, 'frontend-dev': { color: '#38bdf8', emoji: '๐Ÿงฉ', label: 'Frontend Dev' }, 'backend-dev': { color: '#34d399', emoji: 'โš™๏ธ', label: 'Backend Dev' }, 'solana-dev': { color: '#fbbf24', emoji: '๐Ÿฆ€', label: 'Solana Dev' }, gateway: { color: '#94a3b8', emoji: '๐ŸŒ', label: 'Gateway' }, system: { color: '#64748b', emoji: 'โš™๏ธ', label: 'System' }, websocket: { color: '#a78bfa', emoji: '๐Ÿ”Œ', label: 'WebSocket' }, } function getIdentity(name: string) { return AGENT_IDENTITY[name.toLowerCase()] || { color: '#9ca3af', emoji: name.charAt(0).toUpperCase(), label: name.charAt(0).toUpperCase() + name.slice(1), } } function formatTs(ts: number): string { const d = new Date(ts) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } const CATEGORY_META: Record = { chat: { label: 'chat', color: '#a78bfa' }, tools: { label: 'tools', color: '#22d3ee' }, trace: { label: 'trace', color: '#94a3b8' }, system: { label: 'system', color: '#64748b' }, safety: { label: 'safety', color: '#f87171' }, } const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'chat', label: 'Chat' }, { value: 'tools', label: 'Tools' }, { value: 'trace', label: 'Trace' }, { value: 'system', label: 'System' }, { value: 'safety', label: 'Safety' }, ] // โ”€โ”€ Map store data into unified FeedEvents โ”€โ”€ function logsToFeed(logs: LogEntry[]): FeedEvent[] { return logs.map(log => { let category: FeedCategory = 'trace' const src = (log.source || '').toLowerCase() const msg = (log.message || '').toLowerCase() if (src === 'gateway' || src === 'websocket') { if (msg.includes('tool') || msg.includes('spawn')) category = 'tools' else if (log.level === 'error' || msg.includes('security') || msg.includes('blocked')) category = 'safety' else category = 'trace' } else if (msg.includes('safety') || msg.includes('blocked') || msg.includes('injection')) { category = 'safety' } else if (msg.includes('tool')) { category = 'tools' } return { id: log.id, ts: log.timestamp, category, source: log.source || 'system', message: log.message, level: log.level, data: log.data, } }) } interface CommsMessage { id: number conversation_id: string from_agent: string to_agent: string content: string message_type: string metadata: any created_at: number } interface CommsData { messages: CommsMessage[] total: number graph: { edges: { from_agent: string; to_agent: string; message_count: number; last_message_at: number }[] agentStats: { agent: string; sent: number; received: number }[] } source?: { mode: 'seeded' | 'live' | 'mixed' | 'empty'; seededCount: number; liveCount: number } } function commsToFeed(messages: CommsMessage[]): FeedEvent[] { return messages.map(msg => { const isToolCall = msg.message_type === 'tool_call' || Boolean(msg.metadata?.toolName) const toId = getIdentity(msg.to_agent) return { id: `comms-${msg.id}`, ts: msg.created_at * 1000, category: isToolCall ? 'tools' : 'chat', source: msg.from_agent, message: isToolCall ? `tool: ${msg.metadata?.toolName || msg.content}` : `@${toId.label} ${msg.content}`, data: msg.metadata, } }) } function transcriptToFeed(events: AggregateEvent[]): FeedEvent[] { return events.map(e => { let category: FeedCategory = 'chat' if (e.type === 'tool_use' || e.type === 'tool_result') category = 'tools' else if (e.type === 'thinking') category = 'trace' else if (e.role === 'system') category = 'system' return { id: e.id, ts: e.ts, category, source: e.agentName, message: e.type === 'tool_use' ? `tool: ${e.content}` : e.type === 'tool_result' ? `result: ${e.content}` : e.type === 'thinking' ? `[thinking] ${e.content}` : e.content, data: e.metadata, } }) } interface ActivityRecord { id: number type: string actor: string description: string data: any created_at: number } function activitiesToFeed(activities: ActivityRecord[]): FeedEvent[] { return activities.map(a => ({ id: `activity-${a.id}`, ts: a.created_at * 1000, category: 'system' as FeedCategory, source: a.actor || 'system', message: a.description || a.type, level: 'info' as const, data: a.data, })) } // โ”€โ”€ Main component โ”€โ”€ interface Target { type: 'agent' | 'session' name: string sessionKey?: string } export function AgentCommsPanel() { const t = useTranslations('agentComms') const [filter, setFilter] = useState('all') const [commsData, setCommsData] = useState(null) const [transcriptData, setTranscriptData] = useState([]) const [transcriptSessionCount, setTranscriptSessionCount] = useState(0) const [activityEvents, setActivityEvents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [autoScroll, setAutoScroll] = useState(true) const [draft, setDraft] = useState('') const [sending, setSending] = useState(false) const [sendError, setSendError] = useState(null) const [target, setTarget] = useState(null) const feedEndRef = useRef(null) const feedContainerRef = useRef(null) const { logs, sessions, connection, currentUser, } = useMissionControl() // Fetch DB-backed comms messages const fetchComms = useCallback(async () => { try { const res = await fetch('/api/agents/comms?limit=200') if (!res.ok) throw new Error('Failed to fetch') const json = await res.json() setCommsData(json) setError(null) } catch (err: any) { setError(err.message) } finally { setLoading(false) } }, []) useSmartPoll(fetchComms, 15000) // Fetch aggregated transcript events from all gateway sessions const fetchTranscripts = useCallback(async () => { try { const res = await fetch('/api/sessions/transcript/aggregate?limit=200') if (!res.ok) return const json = await res.json() setTranscriptData(json.events || []) setTranscriptSessionCount(json.sessionCount || 0) } catch { // Silent โ€” transcript is supplementary data } }, []) useSmartPoll(fetchTranscripts, 20000) // Fetch memory/agent activity events const fetchActivities = useCallback(async () => { try { const res = await fetch('/api/activities?type=agent_memory_updated,agent_memory_cleared,memory_file_saved,memory_file_created,memory_file_deleted&limit=50') if (!res.ok) return const json = await res.json() setActivityEvents(json.activities || []) } catch { // Silent โ€” activities are supplementary } }, []) useSmartPoll(fetchActivities, 30000) // Merge all sources into a single chronological feed const feedEvents = useMemo(() => { const fromLogs = logsToFeed(logs) const fromComms = commsToFeed(commsData?.messages || []) const fromTranscripts = transcriptToFeed(transcriptData) const fromActivities = activitiesToFeed(activityEvents) const merged = [...fromLogs, ...fromComms, ...fromTranscripts, ...fromActivities] // Deduplicate by id const seen = new Set() const deduped = merged.filter(e => { if (seen.has(e.id)) return false; seen.add(e.id); return true }) deduped.sort((a, b) => a.ts - b.ts) return deduped }, [logs, commsData?.messages, transcriptData, activityEvents]) const filteredFeed = useMemo(() => { if (filter === 'all') return feedEvents return feedEvents.filter(e => e.category === filter) }, [feedEvents, filter]) // Auto-scroll to bottom when new events arrive useEffect(() => { if (autoScroll && feedContainerRef.current) { feedContainerRef.current.scrollTo({ top: feedContainerRef.current.scrollHeight, behavior: 'smooth' }) } }, [filteredFeed.length, autoScroll]) // Detect manual scroll-up to pause auto-scroll const handleScroll = useCallback(() => { const el = feedContainerRef.current if (!el) return const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 setAutoScroll(atBottom) }, []) // Send message to selected target (or coordinator fallback) async function sendMessage() { const content = draft.trim() if (!content || sending) return const toAgent = target?.name || COORDINATOR_AGENT const from = currentUser?.username || currentUser?.display_name || 'operator' setSending(true) setSendError(null) try { const res = await fetch('/api/chat/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: toAgent, content, message_type: 'text', conversation_id: target ? `agent_${toAgent}` : `coord:${from}:${COORDINATOR_AGENT}`, forward: true, ...(target?.sessionKey ? { sessionKey: target.sessionKey } : {}), metadata: target ? undefined : { channel: 'coordinator-inbox' }, }), }) const payload = await res.json().catch(() => ({})) if (!res.ok) { if (res.status === 422 && payload?.injection) { const rules = payload.injection.map((i: any) => i.description || i.rule).join('; ') throw new Error(`Message blocked: content triggered safety filter (${rules})`) } if (res.status === 403) { throw new Error('You need operator access to send messages') } throw new Error(payload?.error || 'Failed to send') } if (payload?.forward?.attempted && !payload?.forward?.delivered) { const reason = payload?.forward?.reason || 'unknown' setSendError(`Sent, but not delivered to a live session (${reason}).`) } setDraft('') await fetchComms() } catch (err) { setSendError((err as Error).message || 'Failed to send') } finally { setSending(false) } } const sourceMode = commsData?.source?.mode || 'empty' const agents = commsData?.graph.agentStats.map(s => s.agent) || [] const categoryCounts = useMemo(() => { const counts: Record = { chat: 0, tools: 0, trace: 0, system: 0, safety: 0 } for (const e of feedEvents) counts[e.category] = (counts[e.category] || 0) + 1 return counts }, [feedEvents]) if (loading && !commsData && logs.length === 0) { return (
{t('connecting')}
) } return (
{/* Header */}
๐Ÿ“ก

# agent-feed

{t('eventsCount', { count: filteredFeed.length })} {/* Connection indicator */} {connection.isConnected ? t('connectionGateway') : connection.sseConnected ? t('connectionSse') : t('connectionPolling')} {sourceMode !== 'empty' && ( {sourceMode === 'live' ? t('sourceLive') : sourceMode === 'mixed' ? t('sourceMixed') : t('sourceSeeded')} )}
{/* Filter bar โ€” matches TUI FeedFilter */}
{FILTER_OPTIONS.map(opt => ( ))} {/* Session count */} {(sessions.length > 0 || transcriptSessionCount > 0) && (
{t('sessions', { active: sessions.filter(s => s.active).length, total: transcriptSessionCount || sessions.length })}
)}
{error && (
{error}
)} {/* Live sessions strip */} {sessions.length > 0 && (
{sessions.map(s => { const agentName = s.key.split(':')[1] || s.kind const isSelected = target?.type === 'session' && target.sessionKey === s.key return ( { if (isSelected) setTarget(null) else setTarget({ type: 'session', name: agentName, sessionKey: s.key }) }} /> ) })}
)} {/* Feed stream */}
{filteredFeed.length === 0 ? ( ) : (
{filteredFeed.map(event => ( ))}
)}
{/* Auto-scroll indicator */} {!autoScroll && filteredFeed.length > 0 && (
)} {/* Online agents bar */} {agents.length > 0 && (
{agents.map(a => { const id = getIdentity(a) const isSelected = target?.type === 'agent' && target.name === a return ( ) })}
)} {/* Composer */}
{target && (
{t('toLabel')}
)}