forked from molecule-ai/molecule-core
### Two unrelated but small UI fixes surfaced while testing the Canvas
**1. Legend hidden under the open TemplatePalette.**
Legend is `fixed bottom-6 left-4 z-30`. TemplatePalette's drawer (when
open) is `fixed top-0 left-0 w-[280px] z-30` — same z-index, same
left-edge column. The Legend overlapped the palette's bottom 180 px.
Published the palette-open state to the canvas store so the Legend
can shift right (to `left-[296px]` — 280 px palette + 16 px gap) while
the palette is open, animated via a 200 ms `transition-[left]` to
match the palette's slide. Closes cleanly back to `left-4` when the
palette is dismissed.
Files:
- `store/canvas.ts` — added `templatePaletteOpen` + `setTemplatePaletteOpen`.
- `TemplatePalette.tsx` — calls `setTemplatePaletteOpen(open)` on
every open/close transition via a new useEffect.
- `Legend.tsx` — reads the flag and swaps `left-4` <-> `left-[296px]`.
**2. "WebSocket is closed before the connection is established" spam.**
Two components (`ChatTab`, `AgentCommsPanel`) open their own short-
lived WebSocket to tail the ACTIVITY_LOGGED stream. Their cleanup
path called `ws.close()` unconditionally, which trips a browser
console warning when React StrictMode re-runs the effect in dev and
the handshake hasn't completed yet. Confirmed via DevTools console
on the running canvas.
Added a `closeWebSocketGracefully(ws)` helper in `lib/ws-close.ts`:
- OPEN / CLOSING → close immediately (normal path).
- CONNECTING → defer close to the 'open' listener so the
browser sees a full handshake. Also wires an
'error' listener that cancels the queued close
if the handshake fails (no double-close).
- CLOSED → no-op.
Both consumers now call the helper in their useEffect cleanup.
Silences the warning without changing observable behaviour.
### Tests
`canvas/src/lib/__tests__/ws-close.test.ts` — 5 cases with a fake
WebSocket covering each readyState branch plus the error-before-open
cancellation path. Full vitest suite: 927/927 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
|
import { WS_URL } from "@/store/socket";
|
|
import { closeWebSocketGracefully } from "@/lib/ws-close";
|
|
import { extractResponseText, extractRequestText } from "./message-parser";
|
|
|
|
interface ActivityEntry {
|
|
id: string;
|
|
activity_type: string;
|
|
source_id: string | null;
|
|
target_id: string | null;
|
|
method: string | null;
|
|
summary: string | null;
|
|
request_body: Record<string, unknown> | null;
|
|
response_body: Record<string, unknown> | null;
|
|
status: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface CommMessage {
|
|
id: string;
|
|
direction: "in" | "out";
|
|
peerName: string;
|
|
peerId: string;
|
|
text: string;
|
|
responseText: string | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
function resolveName(id: string): string {
|
|
const nodes = useCanvasStore.getState().nodes;
|
|
const node = nodes.find((n) => n.id === id);
|
|
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
|
}
|
|
|
|
function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | null {
|
|
const isOutgoing = entry.activity_type === "a2a_send";
|
|
const peerId = isOutgoing ? (entry.target_id || "") : (entry.source_id || "");
|
|
if (!peerId) return null;
|
|
|
|
const text = extractRequestText(entry.request_body) || entry.summary || "";
|
|
const responseText = entry.response_body ? extractResponseText(entry.response_body) : null;
|
|
|
|
return {
|
|
id: entry.id,
|
|
direction: isOutgoing ? "out" : "in",
|
|
peerName: resolveName(peerId),
|
|
peerId,
|
|
text,
|
|
responseText,
|
|
timestamp: entry.created_at,
|
|
};
|
|
}
|
|
|
|
export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
|
const [messages, setMessages] = useState<CommMessage[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
// Dedup by timestamp+type+peer to handle API load + WebSocket race
|
|
const seenKeys = useRef(new Set<string>());
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Load history
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
api.get<ActivityEntry[]>(`/workspaces/${workspaceId}/activity?source=agent&limit=50`)
|
|
.then((entries) => {
|
|
const filtered = entries
|
|
.filter((e) => e.activity_type === "a2a_send" || e.activity_type === "a2a_receive")
|
|
.reverse();
|
|
const msgs: CommMessage[] = [];
|
|
for (const e of filtered) {
|
|
const m = toCommMessage(e, workspaceId);
|
|
if (m) {
|
|
const key = `${m.timestamp}:${m.direction}:${m.peerId}`;
|
|
msgs.push(m);
|
|
seenKeys.current.add(key);
|
|
}
|
|
}
|
|
setMessages(msgs);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, [workspaceId]);
|
|
|
|
// Live updates via WebSocket
|
|
useEffect(() => {
|
|
const ws = new WebSocket(WS_URL);
|
|
ws.onerror = () => {
|
|
console.warn("AgentCommsPanel WS error");
|
|
};
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.event === "ACTIVITY_LOGGED" && msg.workspace_id === workspaceId) {
|
|
const p = msg.payload || {};
|
|
const type = p.activity_type as string;
|
|
const sourceId = p.source_id as string | null;
|
|
if (!sourceId) return; // canvas-initiated, not agent comms
|
|
if (type !== "a2a_send" && type !== "a2a_receive") return;
|
|
|
|
const entry: ActivityEntry = {
|
|
id: p.id as string || crypto.randomUUID(),
|
|
activity_type: type,
|
|
source_id: sourceId,
|
|
target_id: p.target_id as string | null,
|
|
method: p.method as string | null,
|
|
summary: p.summary as string | null,
|
|
request_body: p.request_body as Record<string, unknown> | null,
|
|
response_body: p.response_body as Record<string, unknown> | null,
|
|
status: p.status as string || "ok",
|
|
created_at: msg.timestamp || new Date().toISOString(),
|
|
};
|
|
const m = toCommMessage(entry, workspaceId);
|
|
if (m) {
|
|
const key = `${m.timestamp}:${m.direction}:${m.peerId}`;
|
|
if (seenKeys.current.has(key)) return;
|
|
seenKeys.current.add(key);
|
|
setMessages((prev) => [...prev, m]);
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
return () => {
|
|
closeWebSocketGracefully(ws);
|
|
};
|
|
}, [workspaceId]);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
if (loading) {
|
|
return <div className="text-xs text-zinc-500 text-center py-8">Loading agent communications...</div>;
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
return (
|
|
<div className="text-xs text-zinc-500 text-center py-8">
|
|
No agent-to-agent communications yet.
|
|
<br />
|
|
<span className="text-zinc-600">Delegations and peer messages will appear here.</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
{messages.map((msg) => (
|
|
<div key={msg.id} className={`flex ${msg.direction === "out" ? "justify-end" : "justify-start"}`}>
|
|
<div
|
|
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
|
msg.direction === "out"
|
|
? "bg-cyan-900/30 text-cyan-100 border border-cyan-700/20"
|
|
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
|
|
}`}
|
|
>
|
|
<div className="text-[9px] text-zinc-500 mb-1">
|
|
{msg.direction === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
|
</div>
|
|
<div className="text-zinc-300">{msg.text || "(no message text)"}</div>
|
|
{msg.responseText && (
|
|
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/30 text-zinc-400">
|
|
{msg.responseText}
|
|
</div>
|
|
)}
|
|
<div className="text-[9px] text-zinc-500 mt-1">
|
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
);
|
|
}
|