fix: prevent SSE connection drops from silently truncating responses (#7831)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jh-block
2026-03-12 13:22:35 +01:00
committed by GitHub
parent cd12a01b30
commit f65530bce5
+23 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { AppEvents } from '../constants/events';
import { ChatState } from '../types/chatState';
import { toastError } from '../toasts';
import {
getSession,
@@ -206,7 +207,8 @@ async function streamFromResponse(
initialMessages: Message[],
dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void,
sessionId: string
sessionId: string,
signal?: AbortController['signal']
): Promise<void> {
let currentMessages = initialMessages;
const reduceMotion = prefersReducedMotion();
@@ -309,8 +311,22 @@ async function streamFromResponse(
}
}
// If we reach here, the stream ended without a Finish or Error event.
// This happens when the connection drops and retries are exhausted — the
// generator exits its loop without yielding a terminal event. We call
// onFinish() without an error to keep the conversation visible (passing
// an error would trigger a full-page error screen via sessionLoadError),
// then show a toast so the user knows the response may be incomplete.
// If the signal was aborted, the user intentionally stopped streaming,
// so we skip the toast.
flushBatchedUpdates();
onFinish();
if (!signal?.aborted) {
toastError({
title: 'Connection lost',
msg: 'The response may be incomplete. You can try sending your message again.',
});
}
} catch (error) {
flushBatchedUpdates();
if (error instanceof Error && error.name !== 'AbortError') {
@@ -593,9 +609,10 @@ export function useChatStream({
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
} catch (error) {
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
@@ -634,9 +651,10 @@ export function useChatStream({
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId, abortControllerRef.current.signal);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
@@ -773,9 +791,10 @@ export function useChatStream({
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(stream, messagesForUI, dispatch, onFinish, targetSessionId);
await streamFromResponse(stream, messagesForUI, dispatch, onFinish, targetSessionId, abortControllerRef.current.signal);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });