From 5b146fd24d2012eb00fbc6e6cf698a1b57bc3cc2 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 17:19:23 +0300 Subject: [PATCH] fix(ai-chat): branch sendNow on live status and fix stale queue comment Address review on #198 (interrupt agent / send now): - sendNow now branches on the live useChat status (statusRef) instead of the closure-captured isStreaming. A turn can finish between render and click, where stop() is a no-op; arming flushOnAbortRef/interruptNextSendRef against that no-op would strand the flags and leak into a later, unrelated Stop (auto-sending a queued message the user did not ask to send). - Correct the stale queue comment: onFinish DOES fire on Stop/disconnect/ error (its abort/disconnect/error branches leave the queue intact), and a deliberate "Send now" flushes the promoted head via the abort branch. i18n keys for "Send now"/"Interrupt and send now" were already registered in en-US and ru-RU on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/chat-thread.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 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 024fd316..197859a2 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -182,9 +182,12 @@ export default function ChatThread({ // LOCAL state so it is scoped to this conversation: it is cleared when the user // deliberately switches chat / starts a new chat (the parent remounts this via // `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a - // message queued during a brand-new chat's first turn is not lost. On Stop or - // error the queue is intentionally preserved (onFinish does not fire then) so - // the user decides what to do with the pending messages. + // message queued during a brand-new chat's first turn is not lost. On a normal + // Stop / disconnect / error the queue is intentionally preserved (onFinish DOES + // fire on those — see the abort/disconnect/error branches below — but it leaves + // the queue intact) so the user decides what to do with the pending messages. + // The one exception is a deliberate "Send now" (which itself calls stop()): its + // abort branch in onFinish flushes the message it promoted to the head. const [queued, setQueued] = useState([]); // Mirror the queue in a ref so the `onFinish` flush always reads the latest // queue without a stale closure; `setQueue` updates BOTH the ref and the state. @@ -316,6 +319,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 @@ -352,7 +362,12 @@ export default function ChatThread({ // after the new turn finishes. const sendNow = useCallback( (id: string) => { - if (isStreaming) { + // Branch on the LIVE status (statusRef), not the closure-captured isStreaming: + // the turn may have finished between render and click, in which case stop() + // is a no-op and arming the interrupt refs would strand them for a later turn. + const liveStreaming = + statusRef.current === "submitted" || statusRef.current === "streaming"; + if (liveStreaming) { // Promote the chosen message to the head so the existing onFinish→flushNext // sends exactly it, then interrupt: the abort triggers onFinish below. setQueue(promoteToHead(queuedRef.current, id)); @@ -367,7 +382,7 @@ 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, and drop any