forked from molecule-ai/molecule-core
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>
184 lines
7.0 KiB
TypeScript
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`;
|
|
}
|