feat(ui): show coordination inbox and reservation context
This commit is contained in:
parent
5b18f97b7b
commit
85898a433a
3 changed files with 374 additions and 81 deletions
|
|
@ -31,6 +31,18 @@ interface AgentRosterEntry {
|
||||||
beadId: string;
|
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 {
|
interface ActivityPanelProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
|
|
@ -143,6 +155,20 @@ function getAgentTone(status: AgentStatus): AgentTone {
|
||||||
export function getEventTone(kind: string): EventTone {
|
export function getEventTone(kind: string): EventTone {
|
||||||
const normalized = kind.toLowerCase();
|
const normalized = kind.toLowerCase();
|
||||||
const byKind: Record<string, EventTone> = {
|
const byKind: Record<string, EventTone> = {
|
||||||
|
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: {
|
created: {
|
||||||
label: 'Created',
|
label: 'Created',
|
||||||
labelClass: 'text-[#7CB97A]',
|
labelClass: 'text-[#7CB97A]',
|
||||||
|
|
@ -246,6 +272,8 @@ export function getInitials(name: string): string {
|
||||||
|
|
||||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||||
|
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
|
||||||
|
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
||||||
|
|
@ -269,6 +297,66 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
fetchActivity();
|
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<string, CoordMessage>();
|
||||||
|
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
|
// Subscribe to real-time activity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
|
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
|
||||||
|
|
@ -297,6 +385,12 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
}, [projectRoot]);
|
}, [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) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
|
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
|
||||||
|
|
@ -328,7 +422,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
|
|
||||||
{/* Activity Pulses */}
|
{/* Activity Pulses */}
|
||||||
<div className="flex flex-col gap-2 opacity-40">
|
<div className="flex flex-col gap-2 opacity-40">
|
||||||
{activities.slice(0, 8).map((act) => (
|
{mergedActivities.slice(0, 8).map((act) => (
|
||||||
<div key={act.id} className={cn(
|
<div key={act.id} className={cn(
|
||||||
"w-1 h-1 rounded-full",
|
"w-1 h-1 rounded-full",
|
||||||
getEventTone(act.kind).dotClass
|
getEventTone(act.kind).dotClass
|
||||||
|
|
@ -382,6 +476,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
)}>
|
)}>
|
||||||
{agent.status}
|
{agent.status}
|
||||||
</span>
|
</span>
|
||||||
|
{reservationByAgent[agent.name] ? (
|
||||||
|
<span className="max-w-[140px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservationByAgent[agent.name]}>
|
||||||
|
{reservationByAgent[agent.name]}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="text-[9px] text-text-muted/40 font-mono">
|
<span className="text-[9px] text-text-muted/40 font-mono">
|
||||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -406,13 +505,13 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
||||||
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
|
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
|
||||||
</div>
|
</div>
|
||||||
) : activities.length === 0 ? (
|
) : mergedActivities.length === 0 ? (
|
||||||
<div className="p-10 text-center opacity-30">
|
<div className="p-10 text-center opacity-30">
|
||||||
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
|
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{activities.map((activity) => {
|
{mergedActivities.map((activity) => {
|
||||||
const eventTone = getEventTone(activity.kind);
|
const eventTone = getEventTone(activity.kind);
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="group relative">
|
<div key={activity.id} className="group relative">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
import type { KeyboardEvent, MouseEventHandler } from 'react';
|
||||||
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
|
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -27,6 +28,18 @@ interface SocialCardProps {
|
||||||
archetypes?: AgentArchetype[];
|
archetypes?: AgentArchetype[];
|
||||||
swarmId?: string;
|
swarmId?: string;
|
||||||
onLaunchSwarm?: () => void;
|
onLaunchSwarm?: () => void;
|
||||||
|
agentUnreadByName?: Record<string, number>;
|
||||||
|
agentMessagesByName?: Record<string, Array<{
|
||||||
|
message_id: string;
|
||||||
|
from_agent: string;
|
||||||
|
category: 'HANDOFF' | 'BLOCKED' | 'DECISION' | 'INFO';
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
state: 'unread' | 'read' | 'acked';
|
||||||
|
requires_ack: boolean;
|
||||||
|
}>>;
|
||||||
|
agentReservationsByName?: Record<string, string | undefined>;
|
||||||
|
onAckMessage?: (agent: string, messageId: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
|
||||||
|
|
@ -106,6 +119,13 @@ function dependencyPanel(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export function SocialCard({
|
||||||
data,
|
data,
|
||||||
className,
|
className,
|
||||||
|
|
@ -124,11 +144,17 @@ export function SocialCard({
|
||||||
archetypes = [],
|
archetypes = [],
|
||||||
swarmId,
|
swarmId,
|
||||||
onLaunchSwarm,
|
onLaunchSwarm,
|
||||||
|
agentUnreadByName = {},
|
||||||
|
agentMessagesByName = {},
|
||||||
|
agentReservationsByName = {},
|
||||||
|
onAckMessage,
|
||||||
}: SocialCardProps) {
|
}: SocialCardProps) {
|
||||||
const status = statusVisual(data.status);
|
const status = statusVisual(data.status);
|
||||||
const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker();
|
const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker();
|
||||||
const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0;
|
const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0;
|
||||||
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
|
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
|
||||||
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||||
|
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -176,18 +202,91 @@ export function SocialCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
{data.agents.slice(0, 3).map((agent) => (
|
{data.agents.slice(0, 3).map((agent) => {
|
||||||
<AgentAvatar
|
const unreadCount = agentUnreadByName[agent.name] ?? 0;
|
||||||
key={`${data.id}-${agent.name}`}
|
const reservation = agentReservationsByName[agent.name];
|
||||||
name={agent.name}
|
return (
|
||||||
status={agent.status as AgentStatus}
|
<div key={`${data.id}-${agent.name}`} className="flex items-center gap-1.5">
|
||||||
role={agent.role}
|
<AgentAvatar
|
||||||
size="sm"
|
name={agent.name}
|
||||||
/>
|
status={agent.status as AgentStatus}
|
||||||
))}
|
role={agent.role}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="max-w-[84px] truncate text-[10px] text-[var(--text-tertiary)]">{agent.name}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setExpandedAgent((prev) => (prev === agent.name ? null : agent.name));
|
||||||
|
}}
|
||||||
|
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]"
|
||||||
|
title={`Unread for ${agent.name}`}
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{reservation ? (
|
||||||
|
<span className="max-w-[92px] truncate rounded border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 text-[9px] text-cyan-200" title={reservation}>
|
||||||
|
{reservation}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{data.agents.length === 0 ? <span className="text-xs text-[var(--text-tertiary)]">No crew</span> : null}
|
{data.agents.length === 0 ? <span className="text-xs text-[var(--text-tertiary)]">No crew</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expandedAgent ? (
|
||||||
|
<div className="mt-2 space-y-1.5 rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-2">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--text-tertiary)]">
|
||||||
|
{expandedAgent} inbox
|
||||||
|
</p>
|
||||||
|
{(agentMessagesByName[expandedAgent] ?? []).slice(0, 4).map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.message_id}
|
||||||
|
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] p-2"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${categoryBadgeClass(message.category)}`}>
|
||||||
|
{message.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-tertiary)]">{message.state}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-semibold text-[var(--text-primary)]">{message.subject}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[var(--text-tertiary)]">{message.body}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">from {message.from_agent}</p>
|
||||||
|
{message.requires_ack && message.state !== 'acked' && onAckMessage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setAckingMessageId(message.message_id);
|
||||||
|
try {
|
||||||
|
await onAckMessage(expandedAgent, message.message_id);
|
||||||
|
} finally {
|
||||||
|
setAckingMessageId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={ackingMessageId === message.message_id}
|
||||||
|
className="mt-1 rounded border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-semibold text-amber-200 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{ackingMessageId === message.message_id ? 'Acking...' : 'Ack'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(agentMessagesByName[expandedAgent] ?? []).length === 0 ? (
|
||||||
|
<p className="text-[11px] text-[var(--text-tertiary)]">No messages.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showAssign && (
|
{showAssign && (
|
||||||
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
|
@ -20,6 +20,17 @@ interface SocialPageProps {
|
||||||
onRocketClick?: () => void;
|
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';
|
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
|
||||||
|
|
||||||
const SECTION_LABEL: Record<SectionKey, string> = {
|
const SECTION_LABEL: Record<SectionKey, string> = {
|
||||||
|
|
@ -151,6 +162,86 @@ export function SocialPage({
|
||||||
deferred: true,
|
deferred: true,
|
||||||
done: true,
|
done: true,
|
||||||
});
|
});
|
||||||
|
const [agentMessagesByName, setAgentMessagesByName] = useState<Record<string, CoordMessage[]>>({});
|
||||||
|
const [agentUnreadByName, setAgentUnreadByName] = useState<Record<string, number>>({});
|
||||||
|
const [agentReservationsByName, setAgentReservationsByName] = useState<Record<string, string | undefined>>({});
|
||||||
|
|
||||||
|
const agentNames = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
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<string, CoordMessage[]> = {};
|
||||||
|
const nextUnread: Record<string, number> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
|
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
|
||||||
|
|
@ -245,6 +336,10 @@ export function SocialPage({
|
||||||
archetypes={archetypes}
|
archetypes={archetypes}
|
||||||
swarmId={swarmId}
|
swarmId={swarmId}
|
||||||
onLaunchSwarm={onRocketClick}
|
onLaunchSwarm={onRocketClick}
|
||||||
|
agentUnreadByName={agentUnreadByName}
|
||||||
|
agentMessagesByName={agentMessagesByName}
|
||||||
|
agentReservationsByName={agentReservationsByName}
|
||||||
|
onAckMessage={handleAckMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue