diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index c4732fbec086..03eca84814fa 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -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, onFinish: (error?: string) => void, - sessionId: string + sessionId: string, + signal?: AbortController['signal'] ): Promise { 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 });