molecule-core/canvas/src/components/CommunicationOverlay.tsx
Molecule AI Core-UIUX 6a96641c37 fix(canvas/a11y): add type="button" to remaining canvas component buttons (batch 3)
WCAG 4.1.2 / bug #1669 follow-up — final batch completing the campaign.
Added type="button" to all buttons missing it across 14 canvas components.

Files changed (14, all additions):
- Toolbar.tsx: Stop All, Restart All, A2A toggle, Audit shortcut, Quick help, Search shortcut, Help close (7)
- MemoryInspectorPanel.tsx: scope tabs, refresh, search clear ×2, expand, delete (6)
- TemplatePalette.tsx: org refresh, toggle, Import Agent, org import, deploy template, palette refresh (6)
- ProvisioningTimeout.tsx: Retry, Cancel Request, View Logs, Keep, Remove Workspace (5)
- ConsoleModal.tsx: close, Copy output, Close (3)
- OnboardingWizard.tsx: Skip guide, action, Next (3)
- ConversationTraceModal.tsx: close ×2 (2)
- WorkspaceNode.tsx: Restart banner, Extract from team (2)
- CommunicationOverlay.tsx: toggle, close panel (2)
- Toaster.tsx: dismiss ×2 (2)
- SearchDialog.tsx: search result button (1)
- TermsGate.tsx: accept (1)
- ErrorBoundary.tsx: Reload (1)
- BundleDropZone.tsx: import trigger (1)

Total campaign (batches 1-3): 27 + 42 = 69 buttons fixed across 24 components.
All 477 canvas vitest tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:40:52 +00:00

184 lines
7.0 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useCanvasStore } from "@/store/canvas";
import { api } from "@/lib/api";
import { COMM_TYPE_LABELS } from "@/lib/design-tokens";
interface Communication {
id: string;
sourceId: string;
targetId: string;
sourceName: string;
targetName: string;
type: "a2a_send" | "a2a_receive" | "task_update";
summary: string;
status: string;
timestamp: string;
durationMs: number | null;
}
/**
* Overlay showing recent A2A communications between workspaces.
* Renders as a floating log panel that auto-updates.
*/
export function CommunicationOverlay() {
const [comms, setComms] = useState<Communication[]>([]);
const [visible, setVisible] = useState(true);
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
const nodes = useCanvasStore((s) => s.nodes);
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
const fetchComms = useCallback(async () => {
try {
// Fetch activity from all online workspaces
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
const allComms: Communication[] = [];
for (const node of onlineNodes.slice(0, 6)) {
try {
const activities = await api.get<Array<{
id: string;
workspace_id: string;
activity_type: string;
source_id: string | null;
target_id: string | null;
summary: string | null;
status: string;
duration_ms: number | null;
created_at: string;
}>>(`/workspaces/${node.id}/activity?limit=5`);
for (const a of activities) {
if (a.activity_type === "a2a_send" || a.activity_type === "a2a_receive") {
const sourceNode = nodes.find((n) => n.id === (a.source_id || a.workspace_id));
const targetNode = nodes.find((n) => n.id === (a.target_id || ""));
allComms.push({
id: a.id,
sourceId: a.source_id || a.workspace_id,
targetId: a.target_id || "",
sourceName: sourceNode?.data.name || "Unknown",
targetName: targetNode?.data.name || "Unknown",
type: a.activity_type as Communication["type"],
summary: a.summary || "",
status: a.status,
timestamp: a.created_at,
durationMs: a.duration_ms,
});
}
}
} catch {
// Skip workspaces that fail
}
}
// Sort by timestamp, newest first, dedupe
const seen = new Set<string>();
const sorted = allComms
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
})
.slice(0, 20);
setComms(sorted);
} catch {
// Silently handle API errors
}
}, []);
useEffect(() => {
fetchComms();
const interval = setInterval(fetchComms, 10000);
return () => clearInterval(interval);
}, [fetchComms]);
if (!visible || comms.length === 0) {
return (
<button
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
);
}
return (
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
<span aria-hidden="true"> </span>Communications ({comms.length})
</div>
<button
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-zinc-500 hover:text-zinc-300 text-xs"
>
<span aria-hidden="true"></span>
</button>
</div>
<div className="overflow-y-auto max-h-[350px] p-2 space-y-1">
{comms.map((c) => {
const isSelected = selectedNodeId === c.sourceId || selectedNodeId === c.targetId;
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-blue-400" : "text-amber-400";
const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆";
const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱";
const statusColor = c.status === "ok" ? "text-emerald-400" : c.status === "error" ? "text-red-400" : "text-amber-400";
const age = formatAge(c.timestamp);
return (
<div
key={c.id}
className={`rounded-lg px-2.5 py-1.5 text-[9px] border transition-all ${
isSelected
? "bg-blue-950/30 border-blue-800/40"
: "bg-zinc-800/30 border-zinc-700/20 hover:bg-zinc-800/50"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className={typeColor} aria-hidden="true">{typeIcon}</span>
<span className="sr-only">{COMM_TYPE_LABELS[c.type] ?? c.type}</span>
<span className="text-zinc-300 font-medium truncate">
{c.sourceName}
</span>
<span className="text-zinc-400" aria-hidden="true"></span>
<span className="sr-only">to</span>
<span className="text-zinc-300 truncate">{c.targetName}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
<span className="sr-only">{c.status}</span>
<span className="text-zinc-400">{age}</span>
</div>
</div>
{c.summary && (
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
)}
{c.durationMs && (
<div className="text-zinc-400 pl-4">{c.durationMs}ms</div>
)}
</div>
);
})}
</div>
</div>
);
}
function formatAge(timestamp: string): string {
const diff = Date.now() - new Date(timestamp).getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
return `${Math.floor(diff / 86400000)}d`;
}