Some checks failed
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Failing after 45s
CI / Canvas (Next.js) (pull_request) Failing after 1m52s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 7s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Stage 1 of #61. Replaces the 30s setInterval poll with: 1. One bootstrap fan-out on mount (cap of 3 retained from the 2026-05-04 fix), gives the initial recent-comms window without waiting for live events. 2. useSocketEvent subscription to ACTIVITY_LOGGED — every event with a comm-overlay-relevant activity_type from a visible online workspace prepends to the rendered list. 3. Re-bootstrap on visibility-toggle re-open so the snapshot is fresh after a long collapsed period. No interval poll. Inherits the singleton ReconnectingSocket's reconnect / backoff / health-check guarantees via useSocketEvent. Steady-state HTTP traffic from this overlay drops from ~6 req/min (3 ws × 2 cycles/min) to 0 outside of mount/visibility-toggle bootstraps. Live updates arrive within ~10ms of the server insert instead of after up to 30s. Test changes: - Bootstrap fan-out cap of 3 — kept (was the cadence test's role pre-#61) - 30s cadence test — replaced with "no interval polling" test that pins the absence of any cadence-driven HTTP after bootstrap - Visibility gate test — extended to verify both: no fetches while closed, AND re-bootstrap on re-open - WS subscription tests (new): - WS push extends rendered list with NO HTTP call - WS push for offline workspace ignored - WS push for non-comm activity_type ignored - WS push while collapsed ignored - non-ACTIVITY_LOGGED events ignored Mutation-tested: - drop visibility gate → visibility test fails - drop activity_type filter → "non-comm activity_type" test fails - drop workspace online-set filter → "offline workspace" test fails Full canvas suite: 1393 passing, 0 failing. tsc clean. No API or schema change. ACTIVITY_LOGGED event shape pinned by existing socket-events tests. Hostile self-review (three weakest spots): 1. Sustained WS outage shows stale comms until visibility-toggle re-bootstrap. Acceptable: the singleton socket already auto- reconnects and the comm overlay isn't a critical-path surface. 2. Bootstrap on visibility-toggle costs another 3 HTTP calls each re-open. Acceptable: visibility-toggle is a deliberate user action, not a tight loop. 3. The WS handler reads the latest `nodes` via nodesRef rather than re-subscribing on node changes. By design — the bus listener stays bound for the component lifetime to avoid the "tear-down storm" pattern A2ATopologyOverlay's comment warns about (ref-based current-state lookup, stable subscription). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import { api } from "@/lib/api";
|
|
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
|
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;
|
|
}
|
|
|
|
/** Workspace-server `ACTIVITY_LOGGED` payload shape. Pulled out so the
|
|
* WS handler below has a typed view of the same fields the HTTP
|
|
* bootstrap consumes — drift between the two paths is a class of bug
|
|
* AgentCommsPanel hit historically. */
|
|
interface ActivityLoggedPayload {
|
|
id?: string;
|
|
activity_type?: string;
|
|
source_id?: string | null;
|
|
target_id?: string | null;
|
|
workspace_id?: string;
|
|
summary?: string | null;
|
|
status?: string;
|
|
duration_ms?: number | null;
|
|
created_at?: string;
|
|
}
|
|
|
|
/** Fan-out cap for the bootstrap HTTP fetch on mount / on visibility
|
|
* re-open. Kept at 3 (carried over from the 2026-05-04 fix) so a
|
|
* freshly-mounted overlay on a 15-workspace tenant only spends 3
|
|
* round-trips bootstrapping. Live updates after that arrive via the
|
|
* WS subscription below — no polling, no fan-out to maintain. */
|
|
const BOOTSTRAP_FAN_OUT_CAP = 3;
|
|
|
|
/** Cap on the rendered list. Bootstrap + every WS push prepends, the
|
|
* list is sliced to this size after each update. Mirrors the prior
|
|
* polling-loop behaviour. */
|
|
const COMMS_RENDER_CAP = 20;
|
|
|
|
/**
|
|
* Overlay showing recent A2A communications between workspaces.
|
|
*
|
|
* Update shape (issue #61 Stage 1, replaces the 30s polling loop):
|
|
* - On mount (when visible): one HTTP bootstrap per online workspace,
|
|
* capped at BOOTSTRAP_FAN_OUT_CAP. Yields the initial recent-comms
|
|
* window without waiting for live events.
|
|
* - Steady state: subscribes to ACTIVITY_LOGGED via useSocketEvent.
|
|
* Each event with a matching activity_type from a visible online
|
|
* workspace gets synthesised into a Communication and prepended.
|
|
* - Visibility re-open: re-bootstraps so the user sees the freshest
|
|
* window even if WS was idle while collapsed.
|
|
*
|
|
* No interval poll. The singleton ReconnectingSocket in `store/socket.ts`
|
|
* already owns reconnect/backoff/health-check, and `useSocketEvent`
|
|
* inherits those guarantees. If WS is genuinely unhealthy, the overlay
|
|
* shows the bootstrap snapshot until the next visibility re-open or
|
|
* the next WS reconnect (which fires its own rehydrate burst).
|
|
*/
|
|
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);
|
|
// nodesRef gives the WS handler current node-name resolution without
|
|
// re-subscribing on every node-list change. The bus listener is
|
|
// registered exactly once per mount; subscriber-side filtering reads
|
|
// the latest value via this ref.
|
|
const nodesRef = useRef(nodes);
|
|
nodesRef.current = nodes;
|
|
|
|
const bootstrapComms = useCallback(async () => {
|
|
try {
|
|
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
|
|
const allComms: Communication[] = [];
|
|
|
|
for (const node of onlineNodes.slice(0, BOOTSTRAP_FAN_OUT_CAP)) {
|
|
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 = nodesRef.current.find((n) => n.id === (a.source_id || a.workspace_id));
|
|
const targetNode = nodesRef.current.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 {
|
|
// Per-workspace failures must not blank the panel — the same
|
|
// robustness the polling version had.
|
|
}
|
|
}
|
|
|
|
// Newest-first with id-dedup, capped at COMMS_RENDER_CAP.
|
|
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, COMMS_RENDER_CAP);
|
|
|
|
setComms(sorted);
|
|
} catch {
|
|
// Bootstrap failure is non-blocking — the WS subscription below
|
|
// will populate the panel as live events arrive.
|
|
}
|
|
}, []);
|
|
|
|
// Bootstrap once on mount + every time the user re-opens after a
|
|
// collapse. Closed-panel state intentionally drops live updates so
|
|
// the panel doesn't churn invisible state — the next open reloads.
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
bootstrapComms();
|
|
}, [bootstrapComms, visible]);
|
|
|
|
// Live-update path. Filters server-side ACTIVITY_LOGGED events down
|
|
// to the comm-overlay-relevant subset and prepends each into the
|
|
// rendered list with the same dedup the bootstrap path uses.
|
|
//
|
|
// Scope guard: ignore events for workspaces not in the visible online
|
|
// set, so a user collapsing one workspace doesn't see its comms
|
|
// continue to scroll in. Same shape the bootstrap path applies.
|
|
useSocketEvent((msg) => {
|
|
if (!visible) return;
|
|
if (msg.event !== "ACTIVITY_LOGGED") return;
|
|
|
|
const p = (msg.payload || {}) as ActivityLoggedPayload;
|
|
const type = p.activity_type;
|
|
if (type !== "a2a_send" && type !== "a2a_receive" && type !== "task_update") return;
|
|
|
|
const wsId = msg.workspace_id;
|
|
const onlineSet = new Set(
|
|
nodesRef.current.filter((n) => n.data.status === "online").map((n) => n.id),
|
|
);
|
|
if (!onlineSet.has(wsId)) return;
|
|
|
|
const sourceId = p.source_id || wsId;
|
|
const targetId = p.target_id || "";
|
|
const sourceNode = nodesRef.current.find((n) => n.id === sourceId);
|
|
const targetNode = nodesRef.current.find((n) => n.id === targetId);
|
|
|
|
const incoming: Communication = {
|
|
id: p.id || `${msg.timestamp || Date.now()}:${sourceId}:${targetId}`,
|
|
sourceId,
|
|
targetId,
|
|
sourceName: sourceNode?.data.name || "Unknown",
|
|
targetName: targetNode?.data.name || "Unknown",
|
|
type: type as Communication["type"],
|
|
summary: p.summary || "",
|
|
status: p.status || "ok",
|
|
timestamp: p.created_at || msg.timestamp || new Date().toISOString(),
|
|
durationMs: p.duration_ms ?? null,
|
|
};
|
|
|
|
setComms((prev) => {
|
|
// Prepend, dedup by id, re-cap. Functional setState is necessary
|
|
// because two ACTIVITY_LOGGED events arriving in the same React
|
|
// batch would otherwise read a stale `comms` from the closure.
|
|
const seen = new Set<string>();
|
|
const merged = [incoming, ...prev]
|
|
.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, COMMS_RENDER_CAP);
|
|
return merged;
|
|
});
|
|
});
|
|
|
|
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-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink 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-surface-sunken/95 border border-line/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-line/60">
|
|
<div className="text-[10px] font-semibold text-ink-mid 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-ink-soft hover:text-ink-mid 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-accent" : "text-warm";
|
|
const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆";
|
|
const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱";
|
|
const statusColor = c.status === "ok" ? "text-good" : c.status === "error" ? "text-bad" : "text-warm";
|
|
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-surface-card/30 border-line/20 hover:bg-surface-card/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-ink-mid font-medium truncate">
|
|
{c.sourceName}
|
|
</span>
|
|
<span className="text-ink-mid" aria-hidden="true">→</span>
|
|
<span className="sr-only">to</span>
|
|
<span className="text-ink-mid 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-ink-mid">{age}</span>
|
|
</div>
|
|
</div>
|
|
{c.summary && (
|
|
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
|
|
)}
|
|
{c.durationMs && (
|
|
<div className="text-ink-mid 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`;
|
|
}
|