fix(ai-chat): branch sendNow on live status to defuse stale-status race (#198)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -340,6 +340,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
|
||||
@@ -379,7 +386,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;
|
||||
@@ -393,12 +406,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
|
||||
|
||||
Reference in New Issue
Block a user