@@ -228,6 +228,7 @@ const RECONNECT_TAIL_ERROR =
228228const MAX_RECONNECT_ATTEMPTS = 10
229229const RECONNECT_BASE_DELAY_MS = 1000
230230const RECONNECT_MAX_DELAY_MS = 30_000
231+ const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000
231232const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000
232233const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000
233234const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000
@@ -1470,6 +1471,10 @@ export function useChat(
14701471 ( ) => { }
14711472 )
14721473 const recoveringQueuedSendHandoffRef = useRef < ActiveQueuedSendHandoffRecovery | null > ( null )
1474+ const recoverActiveStreamRef = useRef <
1475+ ( reason : 'pageshow' | 'visible' | 'online' | 'exhausted_recheck' ) => Promise < void >
1476+ > ( async ( ) => { } )
1477+ const reconnectExhaustedRecheckTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
14731478
14741479 const abortControllerRef = useRef < AbortController | null > ( null )
14751480 const detachedChatResolutionControllersRef = useRef < Set < AbortController > > ( new Set ( ) )
@@ -3242,7 +3247,29 @@ export function useChat(
32423247 maxAttempts : MAX_RECONNECT_ATTEMPTS ,
32433248 } )
32443249 if ( streamGenRef . current === gen ) {
3250+ /**
3251+ * Never give up silently: surface the failure so the pane shows why
3252+ * the live stream stopped instead of a torn-down transcript. Callers
3253+ * own the finalize on a false return (every call site finalizes with
3254+ * error: true), which refetches the persisted transcript; if the
3255+ * server turn is still running, the visibility/online recovery path
3256+ * re-attaches on the next pageshow/visible/online event.
3257+ */
32453258 setIsReconnecting ( false )
3259+ setError ( RECONNECT_TAIL_ERROR )
3260+ /**
3261+ * The tab may stay visible (no pageshow/visible/online event will ever
3262+ * fire) while the server turn keeps running detached. One bounded
3263+ * recheck re-enters recovery once the transient network condition has
3264+ * had time to clear; recovery itself no-ops when nothing is active.
3265+ */
3266+ if ( reconnectExhaustedRecheckTimerRef . current ) {
3267+ clearTimeout ( reconnectExhaustedRecheckTimerRef . current )
3268+ }
3269+ reconnectExhaustedRecheckTimerRef . current = setTimeout ( ( ) => {
3270+ reconnectExhaustedRecheckTimerRef . current = null
3271+ void recoverActiveStreamRef . current ( 'exhausted_recheck' )
3272+ } , RECONNECT_EXHAUSTED_RECHECK_MS )
32463273 }
32473274 return false
32483275 } ,
@@ -3251,7 +3278,7 @@ export function useChat(
32513278 retryReconnectRef . current = retryReconnect
32523279
32533280 const recoverActiveStreamFromRedis = useCallback (
3254- async ( reason : 'pageshow' | 'visible' | 'online' ) : Promise < void > => {
3281+ async ( reason : 'pageshow' | 'visible' | 'online' | 'exhausted_recheck' ) : Promise < void > => {
32553282 const startingChatId = chatIdRef . current
32563283 const startingSelectedChatId = selectedChatIdRef . current
32573284 const chatId = startingChatId ?? startingSelectedChatId
@@ -3386,6 +3413,7 @@ export function useChat(
33863413 } ,
33873414 [ getActiveStreamIdForChat , queryClient , resumeOrFinalize , setTransportReconnecting ]
33883415 )
3416+ recoverActiveStreamRef . current = recoverActiveStreamFromRedis
33893417
33903418 useEffect ( ( ) => {
33913419 if ( typeof window === 'undefined' || typeof document === 'undefined' ) return
@@ -3417,6 +3445,10 @@ export function useChat(
34173445 document . removeEventListener ( 'visibilitychange' , handleVisibilityChange )
34183446 window . removeEventListener ( 'pageshow' , handlePageShow )
34193447 window . removeEventListener ( 'online' , handleOnline )
3448+ if ( reconnectExhaustedRecheckTimerRef . current ) {
3449+ clearTimeout ( reconnectExhaustedRecheckTimerRef . current )
3450+ reconnectExhaustedRecheckTimerRef . current = null
3451+ }
34203452 }
34213453 } , [ recoverActiveStreamFromRedis ] )
34223454
0 commit comments