fix(chat): clear sending spinner when any path delivers the reply

Two latent bugs kept the "Processing with Claude Code..." timer ticking
after the agent had already answered:

1. The A2A_RESPONSE store handler wrote into agentMessages[workspaceId]
   (no prefix) but ChatTab's "clear sending" effect subscribed to
   agentMessages["a2a:" + workspaceId]. Keys never matched — the effect
   was dead code from day one. Removed the dead subscription and moved
   the setSending(false) into the pendingAgentMsgs effect so any reply
   delivered via a WS push (Claude Code SDK, Hermes's
   send_message_to_user) also closes the spinner.

2. Added an activity-log fallback: when the platform emits a successful
   a2a_receive ACTIVITY_LOGGED for this workspace, clear sending and
   stop the timer. That covers the "runtime answered but we never saw
   the store message" case Claude Code exhibited tonight — the HTTP
   request can stay in flight while the SDK already pushed its reply.

Symmetric a2a_receive error path also clears sending and surfaces the
error message, so a runtime-side failure no longer hangs the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 17:43:25 -07:00
parent 1c60869e1e
commit 7356cf8d3a

View File

@ -200,7 +200,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Consume agent push messages (send_message_to_user) from global store
// Consume agent push messages (send_message_to_user) from global store.
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
// than the /a2a HTTP response — when that happens, the push is the
// authoritative "reply arrived" signal for the UI, so clear `sending`
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
// whichever path clears first wins.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
@ -213,23 +218,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
// push for the same content).
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
}
}, [pendingAgentMsgs, workspaceId]);
// Consume A2A_RESPONSE events from global store (streaming response delivery).
// Guarded by sendingFromAPIRef to avoid duplicate messages when the
// synchronous HTTP .then() handler also fires for the same response.
const pendingA2AResponse = useCanvasStore((s) => s.agentMessages[`a2a:${workspaceId}`]);
useEffect(() => {
if (!pendingA2AResponse || pendingA2AResponse.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(`a2a:${workspaceId}`);
if (!sendingFromAPIRef.current) return; // HTTP .then() already handled this response
for (const m of msgs) {
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content)));
if (sendingFromAPIRef.current && msgs.length > 0) {
setSending(false);
sendingFromAPIRef.current = false;
}
setSending(false);
sendingFromAPIRef.current = false;
}, [pendingA2AResponse, workspaceId]);
}, [pendingAgentMsgs, workspaceId]);
// Resolve workspace ID → name for activity display
const resolveWorkspaceName = useCallback((id: string) => {
@ -281,8 +274,24 @@ function MyChatPanel({ workspaceId, data }: Props) {
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
// The platform logs a successful a2a_receive once the workspace
// has fully produced its reply. That's the authoritative "done"
// signal for the spinner — clear it even if the reply hasn't
// surfaced through the store yet (it may be delivered shortly
// via pendingAgentMsgs or the HTTP .then()).
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
setSending(false);
sendingFromAPIRef.current = false;
}
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
setSending(false);
sendingFromAPIRef.current = false;
setError("Agent error (Exception) — see workspace logs for details.");
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
@ -301,7 +310,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
setActivityLog((prev) => [...prev.slice(-8), `${task}`]);
}
}
// A2A_RESPONSE is handled by the store (pendingA2AResponse effect) — no duplicate here
// A2A_RESPONSE is already consumed by the store and its text is
// appended to messages via the pendingAgentMsgs effect above; we
// don't need to duplicate it here.
} catch { /* ignore */ }
};