From 3fa0c67fc6cb91d693096632f4b1473ec07e375e Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 17:38:44 +0300 Subject: [PATCH] fix(ai-chat): branch sendNow on live status to defuse stale-status race (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the only substantive fix #211 was missing relative to #203 (which is being closed): the "Send now" handler branched on the closure-captured isStreaming, but a turn can finish between render and click. In that window stop() is a no-op, so arming flushOnAbortRef/interruptNextSendRef would strand those one-shot flags and leak into a later, unrelated Stop (auto-sending a queued message the user never asked to send). - Mirror the live useChat status in statusRef (updated each render) and branch sendNow on it instead of isStreaming, so the not-streaming path runs when the turn has already ended and the interrupt flags are never armed against a no-op stop(). - Belt-and-suspenders: clear flushOnAbortRef/interruptNextSendRef when a new turn starts streaming, defusing the sub-render-tick window where a flag could still be armed but the expected abort never fired. No-op for the legit interrupt path (both refs are consumed synchronously beforehand). Keeps #211's existing structure and its flushNext-returns-boolean fix. The rest of #203's divergence is comment rewording, a server-side rename of the same pure interrupt-gate, and fewer tests — nothing else to port. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/chat-thread.tsx | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 940c789c..b7cd1bd1 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -328,6 +328,13 @@ export default function ChatThread({ // Keep the flush helper pointed at the latest sendMessage instance. sendMessageRef.current = sendMessage; + // Mirror the live turn status in a ref so event handlers (sendNow) branch on the + // CURRENT status rather than a value captured in a stale render closure — a turn + // can finish between render and click, and arming the interrupt refs against a + // no-op stop() would leave them set to leak into a later, unrelated Stop. + const statusRef = useRef(status); + statusRef.current = status; + // EARLY chat-id adoption (#174): the server streams the authoritative chat id // on the assistant message metadata at the `start` chunk (message.metadata. // chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent @@ -367,7 +374,13 @@ export default function ChatThread({ // interrupt so the server notes the previous answer was cut off. const sendNow = useCallback( (id: string) => { - if (isStreaming) { + // Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming: + // the turn may have finished between this render and the click, in which case + // stop() is a no-op and arming the interrupt refs would strand them for a + // later, unrelated Stop. Reading the ref always sees the current status. + const liveStreaming = + statusRef.current === "submitted" || statusRef.current === "streaming"; + if (liveStreaming) { // Promote to head so the onFinish -> flushNext path sends exactly it. setQueue(promoteToHead(queuedRef.current, id)); flushOnAbortRef.current = true; @@ -381,12 +394,21 @@ export default function ChatThread({ sendMessageRef.current?.({ text: msg.text }); } }, - [isStreaming, setQueue, stop], + [setQueue, stop], ); - // Clear the stopped marker as soon as a new turn begins streaming. + // Clear the stopped marker as soon as a new turn begins streaming, and drop any + // stale "Send now" interrupt flags. On the legit interrupt path both refs are + // already consumed synchronously (onFinish + prepareSendMessagesRequest) before + // this effect runs, so clearing here is a no-op for it; its purpose is to defuse + // the race where a flag was armed but the expected abort never fired (the turn + // finished in the same tick as the click), so it cannot leak into a later turn. useEffect(() => { - if (isStreaming) setStopNotice(null); + if (isStreaming) { + setStopNotice(null); + flushOnAbortRef.current = false; + interruptNextSendRef.current = false; + } }, [isStreaming]); // Classify the turn error into a heading + detail so the banner names the cause