From 7356cf8d3ab2108fefbc96c47532b7fede4a58d0 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 17:43:25 -0700 Subject: [PATCH] fix(chat): clear sending spinner when any path delivers the reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- canvas/src/components/tabs/ChatTab.tsx | 47 ++++++++++++++++---------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index daf6d48f..db4da393 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -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 */ } };