From 663c5b7e70a273c0c55f432cb4ae2c672d722d4d Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 3 May 2026 18:02:30 -0700 Subject: [PATCH] canvas/AgentCommsPanel: add per-peer waiting-for-reply bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the bouncing-dots indicator ChatTab already shows while waiting for an agent reply. Before this, an operator delegating to one or more external peers via Agent Comms saw their outbound bubble land and then silence until the reply (or queued/failed status) arrived — no visual "the system is working on this" cue. Per-peer not global: when multiple delegations are in flight to different peers (the fan-out case), one shared spinner under-reports — the user can't tell whether ALL peers are still working or just the visible ones. Per-peer matches Slack typing-indicator semantics and keeps the signal honest. Detection rule: walk visible messages, keep only the chronologically- last bubble per peer. If that tail is `flow === "out"` AND status is "pending" or "queued", emit a waiting bubble. Once an inbound reply lands, the tail flips to "in" and the bubble disappears — even if the backend hasn't mutated the original outbound row to "completed" yet. This collapses both states into one rule. Visual: matches the outgoing bubble (cyan-900/30 + cyan-700/20 border, right-justified) with cyan-300/70 dots that respect prefers-reduced- motion via `motion-safe:animate-bounce`. Queued case adds copy explaining the peer is busy. role="status" + aria-label so SR users also hear "Waiting for reply from ". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/tabs/chat/AgentCommsPanel.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 268953ce..fc327ea0 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -472,6 +472,7 @@ function GroupedCommsView({ ), )} +
@@ -560,6 +561,83 @@ function PeerTabButton({ ); } +/** WaitingBubbles renders one "typing" bubble per peer that has an + * in-flight outbound delegation — i.e., the most recent outbound + * message to that peer is still pending or queued and no later inbound + * reply has arrived. Mirrors the bouncing-dots indicator in ChatTab so + * the operator sees the same visual cue regardless of whether they're + * watching their own chat or a peer thread. + * + * Why "per peer" not "one global": when multiple delegations are in + * flight to different peers (common during fan-out), one shared + * spinner under-reports — the user can't tell whether ALL peers are + * still working or only the visible ones. Per-peer matches Slack-style + * typing indicators and keeps the signal honest. + * + * Why we look at the LAST per-peer message: once a peer replies (an + * "in" bubble lands), the corresponding "out" bubble is no longer the + * tail — even if status hasn't been mutated to "completed", the inbound + * reply means the wait is over. Looking at the tail collapses both + * cases into one rule. + */ +function WaitingBubbles({ visible }: { visible: CommMessage[] }) { + // Group by peer, keep only the chronologically-last message per peer, + // emit a bubble when that tail is an outbound pending/queued. + const tailByPeer = new Map(); + for (const m of visible) { + const prev = tailByPeer.get(m.peerId); + if (!prev || m.timestamp > prev.timestamp) tailByPeer.set(m.peerId, m); + } + const waitingPeers = Array.from(tailByPeer.values()).filter( + (m) => m.flow === "out" && (m.status === "pending" || m.status === "queued"), + ); + if (waitingPeers.length === 0) return null; + return ( + <> + {waitingPeers.map((m) => ( +
+
+
→ To {m.peerName}
+ +
+
+ ))} + + ); +} + function NormalMessage({ msg }: { msg: CommMessage }) { return (