diff --git a/src/components/activity/activity-panel.tsx b/src/components/activity/activity-panel.tsx index f4b4598..9b54178 100644 --- a/src/components/activity/activity-panel.tsx +++ b/src/components/activity/activity-panel.tsx @@ -24,12 +24,24 @@ export type EventTone = { idClass: string; }; -interface AgentRosterEntry { - name: string; - status: AgentStatus; - lastSeen: string | null; - beadId: string; -} +interface AgentRosterEntry { + name: string; + status: AgentStatus; + lastSeen: string | null; + beadId: string; +} + +interface CoordMessage { + message_id: string; + bead_id: string; + from_agent: string; + to_agent: string; + category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'; + subject: string; + state: 'unread' | 'read' | 'acked'; + created_at: string; + acked_at: string | null; +} interface ActivityPanelProps { issues: BeadIssue[]; @@ -140,9 +152,23 @@ function getAgentTone(status: AgentStatus): AgentTone { } // reopened=blue, closed=amber, created/opened=green, others semantic -export function getEventTone(kind: string): EventTone { - const normalized = kind.toLowerCase(); - const byKind: Record = { +export function getEventTone(kind: string): EventTone { + const normalized = kind.toLowerCase(); + const byKind: Record = { + coord_send: { + label: 'Coord Send', + labelClass: 'text-[#D4A574]', + dotClass: 'bg-[#D4A574]', + cardClass: 'bg-[var(--status-in-progress)]', + idClass: 'text-[#DAB891]', + }, + coord_ack: { + label: 'Coord Ack', + labelClass: 'text-[#7CB97A]', + dotClass: 'bg-[#7CB97A]', + cardClass: 'bg-[var(--status-ready)]', + idClass: 'text-[#9ACB98]', + }, created: { label: 'Created', labelClass: 'text-[#7CB97A]', @@ -244,30 +270,92 @@ export function getInitials(name: string): string { return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2); } -export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) { - const [activities, setActivities] = useState([]); - const [isLoading, setIsLoading] = useState(true); +export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) { + const [activities, setActivities] = useState([]); + const [coordActivities, setCoordActivities] = useState([]); + const [reservationByAgent, setReservationByAgent] = useState>({}); + const [isLoading, setIsLoading] = useState(true); const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]); - // Fetch activity history - useEffect(() => { - async function fetchActivity() { + // 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(); - }, []); + } + } catch (error) { + console.error('[ActivityPanel] Failed to fetch activity:', error); + } finally { + setIsLoading(false); + } + } + + fetchActivity(); + }, []); + + useEffect(() => { + const fetchCoordination = async () => { + if (agentRoster.length === 0) { + setCoordActivities([]); + setReservationByAgent({}); + return; + } + + const mailResponses = await Promise.all( + agentRoster.map(async (agent) => { + const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent.name)}&limit=15`); + const payload = await response.json().catch(() => ({ ok: false })); + return [agent.name, response.ok && payload.ok ? (payload.data as CoordMessage[]) : []] as const; + }), + ); + + const reservationResponses = await Promise.all( + agentRoster.map(async (agent) => { + const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent.name)}`); + const payload = await response.json().catch(() => ({ ok: false })); + if (!response.ok || !payload.ok) { + return [agent.name, undefined] as const; + } + return [agent.name, payload.data?.reservations?.[0]?.scope as string | undefined] as const; + }), + ); + + const uniqueMessages = new Map(); + for (const [, messages] of mailResponses) { + for (const message of messages) { + uniqueMessages.set(message.message_id, message); + } + } + + const mapped = [...uniqueMessages.values()] + .map((message) => ({ + id: `coord-${message.message_id}`, + kind: (message.state === 'acked' ? 'coord_ack' : 'coord_send') as ActivityEvent['kind'], + beadId: message.bead_id, + beadTitle: `${message.category}: ${message.subject}`, + timestamp: message.state === 'acked' && message.acked_at ? message.acked_at : message.created_at, + actor: message.state === 'acked' ? message.to_agent : message.from_agent, + projectId: projectRoot, + projectName: 'beadboard', + payload: {}, + })) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 25); + + setCoordActivities(mapped); + setReservationByAgent(Object.fromEntries(reservationResponses)); + }; + + void fetchCoordination(); + const timer = setInterval(() => { + void fetchCoordination(); + }, 15000); + return () => clearInterval(timer); + }, [agentRoster, projectRoot]); // Subscribe to real-time activity useEffect(() => { @@ -296,7 +384,13 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi }; }, [projectRoot]); - const activeAgents = agentRoster.filter(a => a.status === 'active').length; + const activeAgents = agentRoster.filter(a => a.status === 'active').length; + const mergedActivities = useMemo( + () => [...coordActivities, ...activities] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 50), + [activities, coordActivities], + ); if (collapsed) { return (
@@ -328,7 +422,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi {/* Activity Pulses */}
- {activities.slice(0, 8).map((act) => ( + {mergedActivities.slice(0, 8).map((act) => (
{agent.name}
- - {agent.status} - - - {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'} - + + {agent.status} + + {reservationByAgent[agent.name] ? ( + + {reservationByAgent[agent.name]} + + ) : null} + + {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'} +
@@ -406,13 +505,13 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
SYNCING...
- ) : activities.length === 0 ? ( + ) : mergedActivities.length === 0 ? (

VOID_STREAM_NULL

) : (
- {activities.map((activity) => { + {mergedActivities.map((activity) => { const eventTone = getEventTone(activity.kind); return (
diff --git a/src/components/social/social-card.tsx b/src/components/social/social-card.tsx index 673e504..b03e5e2 100644 --- a/src/components/social/social-card.tsx +++ b/src/components/social/social-card.tsx @@ -1,4 +1,5 @@ -import type { KeyboardEvent, MouseEventHandler } from 'react'; +import { useState } from 'react'; +import type { KeyboardEvent, MouseEventHandler } from 'react'; import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -9,7 +10,7 @@ import { AgentAvatar } from '../shared/agent-avatar'; import { useArchetypePicker } from '../../hooks/use-archetype-picker'; import type { AgentArchetype } from '../../lib/types-swarm'; -interface SocialCardProps { +interface SocialCardProps { data: SocialCardData; className?: string; selected?: boolean; @@ -26,8 +27,20 @@ interface SocialCardProps { unblocksDetails?: Array<{ id: string; title: string; epic?: string }>; archetypes?: AgentArchetype[]; swarmId?: string; - onLaunchSwarm?: () => void; -} + onLaunchSwarm?: () => void; + agentUnreadByName?: Record; + agentMessagesByName?: Record>; + agentReservationsByName?: Record; + onAckMessage?: (agent: string, messageId: string) => Promise | void; +} function handleCardKeyDown(event: KeyboardEvent, onClick?: MouseEventHandler) { if (!onClick) return; @@ -72,7 +85,7 @@ function statusVisual(status: SocialCardData['status']) { }; } -function dependencyPanel( +function dependencyPanel( title: string, color: string, details: Array<{ id: string; title: string; epic?: string }>, @@ -104,9 +117,16 @@ function dependencyPanel( {details.length > 1 ?

+{details.length - 1} more

: null}
); -} - -export function SocialCard({ +} + +function categoryBadgeClass(category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'): string { + if (category === 'BLOCKED') return 'bg-red-600/25 text-red-200 border-red-500/40'; + if (category === 'HANDOFF') return 'bg-amber-500/20 text-amber-200 border-amber-400/40'; + if (category === 'DECISION') return 'bg-sky-500/20 text-sky-200 border-sky-400/40'; + return 'bg-slate-600/20 text-slate-200 border-slate-500/40'; +} + +export function SocialCard({ data, className, selected = false, @@ -121,14 +141,20 @@ export function SocialCard({ unreadCount = 0, blockedByDetails = [], unblocksDetails = [], - archetypes = [], - swarmId, - onLaunchSwarm, -}: SocialCardProps) { - const status = statusVisual(data.status); - const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker(); - const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0; - const isSwarmHighlighted = swarmId && data.id.includes(swarmId); + archetypes = [], + swarmId, + onLaunchSwarm, + agentUnreadByName = {}, + agentMessagesByName = {}, + agentReservationsByName = {}, + onAckMessage, +}: SocialCardProps) { + const status = statusVisual(data.status); + const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker(); + const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0; + const isSwarmHighlighted = swarmId && data.id.includes(swarmId); + const [expandedAgent, setExpandedAgent] = useState(null); + const [ackingMessageId, setAckingMessageId] = useState(null); return (
-
- {data.agents.slice(0, 3).map((agent) => ( - - ))} - {data.agents.length === 0 ? No crew : null} -
+
+ {data.agents.slice(0, 3).map((agent) => { + const unreadCount = agentUnreadByName[agent.name] ?? 0; + const reservation = agentReservationsByName[agent.name]; + return ( +
+ +
+ {agent.name} +
+ {unreadCount > 0 ? ( + + ) : null} + {reservation ? ( + + {reservation} + + ) : null} +
+
+
+ ); + })} + {data.agents.length === 0 ? No crew : null} +
+ + {expandedAgent ? ( +
+

+ {expandedAgent} inbox +

+ {(agentMessagesByName[expandedAgent] ?? []).slice(0, 4).map((message) => ( +
+
+ + {message.category} + + {message.state} +
+

{message.subject}

+

{message.body}

+

from {message.from_agent}

+ {message.requires_ack && message.state !== 'acked' && onAckMessage ? ( + + ) : null} +
+ ))} + {(agentMessagesByName[expandedAgent] ?? []).length === 0 ? ( +

No messages.

+ ) : null} +
+ ) : null} {showAssign && (
e.stopPropagation()}> diff --git a/src/components/social/social-page.tsx b/src/components/social/social-page.tsx index 3608101..a64d78f 100644 --- a/src/components/social/social-page.tsx +++ b/src/components/social/social-page.tsx @@ -1,7 +1,7 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import type { BeadIssue } from '../../lib/types'; import type { ProjectScopeOption } from '../../lib/project-scope'; @@ -9,7 +9,7 @@ import { buildSocialCards } from '../../lib/social-cards'; import { SocialCard } from './social-card'; import { useArchetypes } from '../../hooks/use-archetypes'; -interface SocialPageProps { +interface SocialPageProps { issues: BeadIssue[]; selectedId?: string; onSelect: (id: string) => void; @@ -18,7 +18,18 @@ interface SocialPageProps { projectRoot: string; swarmId?: string; onRocketClick?: () => void; -} +} + +interface CoordMessage { + message_id: string; + from_agent: string; + to_agent: string; + category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO'; + subject: string; + body: string; + state: 'unread' | 'read' | 'acked'; + requires_ack: boolean; +} type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done'; @@ -61,7 +72,7 @@ function formatRelative(timestamp: string): string { return `${diffDays}d ago`; } -export function SocialPage({ +export function SocialPage({ issues, selectedId, onSelect, @@ -137,20 +148,100 @@ export function SocialPage({ return map; }, [visibleCards]); - const [expandedSections, setExpandedSections] = useState>({ + const [expandedSections, setExpandedSections] = useState>({ ready: false, in_progress: false, blocked: false, deferred: false, done: false, }); - const [collapsedSections, setCollapsedSections] = useState>({ + const [collapsedSections, setCollapsedSections] = useState>({ ready: false, in_progress: false, blocked: false, deferred: true, done: true, - }); + }); + const [agentMessagesByName, setAgentMessagesByName] = useState>({}); + const [agentUnreadByName, setAgentUnreadByName] = useState>({}); + const [agentReservationsByName, setAgentReservationsByName] = useState>({}); + + const agentNames = useMemo(() => { + const set = new Set(); + for (const card of visibleCards) { + for (const agent of card.agents) { + if (agent.name) set.add(agent.name); + } + } + return [...set]; + }, [visibleCards]); + + const refreshCoordination = useCallback(async () => { + if (agentNames.length === 0) { + setAgentMessagesByName({}); + setAgentUnreadByName({}); + setAgentReservationsByName({}); + return; + } + + const mailPairs = await Promise.all( + agentNames.map(async (agent) => { + const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent)}&limit=25`); + const payload = await response.json().catch(() => ({ ok: false })); + if (!response.ok || !payload.ok) { + return [agent, [] as CoordMessage[]] as const; + } + return [agent, (payload.data ?? []) as CoordMessage[]] as const; + }), + ); + + const reservationsPairs = await Promise.all( + agentNames.map(async (agent) => { + const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent)}`); + const payload = await response.json().catch(() => ({ ok: false })); + if (!response.ok || !payload.ok) { + return [agent, undefined] as const; + } + const first = (payload.data?.reservations ?? [])[0]; + return [agent, first?.scope as string | undefined] as const; + }), + ); + + const nextMessages: Record = {}; + const nextUnread: Record = {}; + for (const [agent, messages] of mailPairs) { + nextMessages[agent] = messages; + nextUnread[agent] = messages.filter((m) => m.state === 'unread').length; + } + setAgentMessagesByName(nextMessages); + setAgentUnreadByName(nextUnread); + setAgentReservationsByName(Object.fromEntries(reservationsPairs)); + }, [agentNames]); + + useEffect(() => { + let active = true; + const run = async () => { + if (!active) return; + await refreshCoordination(); + }; + void run(); + const timer = setInterval(() => { + void run(); + }, 15000); + return () => { + active = false; + clearInterval(timer); + }; + }, [refreshCoordination]); + + const handleAckMessage = async (agent: string, messageId: string) => { + await fetch('/api/agents/mail/ack', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent, message: messageId }), + }); + await refreshCoordination(); + }; return (
@@ -210,7 +301,7 @@ export function SocialPage({ const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length; return ( - - ); - })} + onLaunchSwarm={onRocketClick} + agentUnreadByName={agentUnreadByName} + agentMessagesByName={agentMessagesByName} + agentReservationsByName={agentReservationsByName} + onAckMessage={handleAckMessage} + /> + ); + })} {cardsForSection.length === 0 ? (

No tasks in this lane.