feat(ui): show coordination inbox and reservation context

This commit is contained in:
ZenchantLive 2026-03-03 18:41:39 -08:00
parent 5b18f97b7b
commit 85898a433a
3 changed files with 374 additions and 81 deletions

View file

@ -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<string, EventTone> = {
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
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: {
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<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [coordActivities, setCoordActivities] = useState<ActivityEvent[]>([]);
const [reservationByAgent, setReservationByAgent] = useState<Record<string, string | undefined>>({});
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<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
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 (
<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 */}
<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(
"w-1 h-1 rounded-full",
getEventTone(act.kind).dotClass
@ -376,15 +470,20 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
<div className="flex items-center gap-1.5">
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</span>
<span className="text-[9px] text-text-muted/40 font-mono">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</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">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
</div>
</div>
</div>
@ -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" />
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
</div>
) : activities.length === 0 ? (
) : mergedActivities.length === 0 ? (
<div className="p-10 text-center opacity-30">
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
</div>
) : (
<div className="p-3 space-y-3">
{activities.map((activity) => {
{mergedActivities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group relative">

View file

@ -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<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>) {
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 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
</div>
);
}
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<string | null>(null);
const [ackingMessageId, setAckingMessageId] = useState<string | null>(null);
return (
<div
@ -175,18 +201,91 @@ export function SocialCard({
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
</div>
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => (
<AgentAvatar
key={`${data.id}-${agent.name}`}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length === 0 ? <span className="text-xs text-[var(--text-tertiary)]">No crew</span> : null}
</div>
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => {
const unreadCount = agentUnreadByName[agent.name] ?? 0;
const reservation = agentReservationsByName[agent.name];
return (
<div key={`${data.id}-${agent.name}`} className="flex items-center gap-1.5">
<AgentAvatar
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}
</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 && (
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>

View file

@ -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<Record<SectionKey, boolean>>({
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: 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 (
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
@ -210,7 +301,7 @@ export function SocialPage({
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
@ -244,10 +335,14 @@ export function SocialPage({
unblocksDetails={toDependencyDetails(card.blocks)}
archetypes={archetypes}
swarmId={swarmId}
onLaunchSwarm={onRocketClick}
/>
);
})}
onLaunchSwarm={onRocketClick}
agentUnreadByName={agentUnreadByName}
agentMessagesByName={agentMessagesByName}
agentReservationsByName={agentReservationsByName}
onAckMessage={handleAckMessage}
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.