@@ -26,6 +26,10 @@ type UseGatewayChatStreamOptions = {
2626 ) => void
2727 /** Callback when a tool approval is requested */
2828 onApprovalRequest ?: ( approval : Record < string , unknown > ) => void
29+ /** Callback when the SSE connection reconnects after a prior open */
30+ onReconnect ?: ( ) => void
31+ /** Callback when the stream stays silent for too long */
32+ onSilentTimeout ?: ( silentForMs : number ) => void
2933}
3034
3135export function useGatewayChatStream (
@@ -38,6 +42,8 @@ export function useGatewayChatStream(
3842 onThinking,
3943 onDone,
4044 onApprovalRequest,
45+ onReconnect,
46+ onSilentTimeout,
4147 } = options
4248
4349 const connectionState = useGatewayChatStore ( ( s ) => s . connectionState )
@@ -54,18 +60,27 @@ export function useGatewayChatStream(
5460 > ( new Map ( ) )
5561 const reconnectAttempts = useRef ( 0 )
5662 const mountedRef = useRef ( true )
63+ const hasConnectedOnceRef = useRef ( false )
64+ const lastActivityAtRef = useRef ( 0 )
65+ const silenceProbeRef = useRef < ReturnType < typeof setInterval > | null > ( null )
66+ const handlingSilenceRef = useRef ( false )
67+ const scheduleReconnectRef = useRef < ( ) => void > ( ( ) => { } )
5768
5869 // Store callbacks in refs to avoid reconnecting when they change
5970 const onUserMessageRef = useRef ( onUserMessage )
6071 const onChunkRef = useRef ( onChunk )
6172 const onThinkingRef = useRef ( onThinking )
6273 const onDoneRef = useRef ( onDone )
6374 const onApprovalRequestRef = useRef ( onApprovalRequest )
75+ const onReconnectRef = useRef ( onReconnect )
76+ const onSilentTimeoutRef = useRef ( onSilentTimeout )
6477 onUserMessageRef . current = onUserMessage
6578 onChunkRef . current = onChunk
6679 onThinkingRef . current = onThinking
6780 onDoneRef . current = onDone
6881 onApprovalRequestRef . current = onApprovalRequest
82+ onReconnectRef . current = onReconnect
83+ onSilentTimeoutRef . current = onSilentTimeout
6984
7085 const dispatchSSEDroppedEvent = useCallback ( ( ) => {
7186 if ( typeof window === 'undefined' ) return
@@ -114,6 +129,37 @@ export function useGatewayChatStream(
114129 streamTimeoutsRef . current . clear ( )
115130 } , [ ] )
116131
132+ const clearSilenceProbe = useCallback ( ( ) => {
133+ if ( ! silenceProbeRef . current ) return
134+ clearInterval ( silenceProbeRef . current )
135+ silenceProbeRef . current = null
136+ } , [ ] )
137+
138+ const markActivity = useCallback ( ( ) => {
139+ lastActivityAtRef . current = Date . now ( )
140+ handlingSilenceRef . current = false
141+ } , [ ] )
142+
143+ const startSilenceProbe = useCallback ( ( ) => {
144+ clearSilenceProbe ( )
145+ lastActivityAtRef . current = Date . now ( )
146+ silenceProbeRef . current = setInterval ( ( ) => {
147+ if ( ! mountedRef . current ) return
148+ if ( eventSourceRef . current ?. readyState !== EventSource . OPEN ) return
149+ const silentForMs = Date . now ( ) - lastActivityAtRef . current
150+ if ( silentForMs < 30_000 ) return
151+ if ( handlingSilenceRef . current ) return
152+ handlingSilenceRef . current = true
153+ onSilentTimeoutRef . current ?.( silentForMs )
154+ if ( eventSourceRef . current ) {
155+ eventSourceRef . current . close ( )
156+ eventSourceRef . current = null
157+ }
158+ setConnectionState ( 'disconnected' )
159+ scheduleReconnectRef . current ( )
160+ } , 15_000 )
161+ } , [ clearSilenceProbe , setConnectionState ] )
162+
117163 const connect = useCallback ( ( ) => {
118164 if ( ! enabled || ! mountedRef . current ) return
119165
@@ -140,19 +186,28 @@ export function useGatewayChatStream(
140186 // Native open event fires on initial connect AND every auto-reconnect
141187 eventSource . onopen = ( ) => {
142188 if ( ! mountedRef . current ) return
189+ const wasConnectedBefore = hasConnectedOnceRef . current
190+ hasConnectedOnceRef . current = true
143191 reconnectAttempts . current = 0
192+ markActivity ( )
193+ startSilenceProbe ( )
144194 // Mark connected immediately — don't wait for custom 'connected' event
145195 setConnectionState ( 'connected' )
196+ if ( wasConnectedBefore ) {
197+ onReconnectRef . current ?.( )
198+ }
146199 }
147200
148201 eventSource . addEventListener ( 'connected' , ( ) => {
149202 if ( ! mountedRef . current ) return
150203 reconnectAttempts . current = 0
204+ markActivity ( )
151205 setConnectionState ( 'connected' )
152206 } )
153207
154208 eventSource . addEventListener ( 'disconnected' , ( ) => {
155209 if ( ! mountedRef . current ) return
210+ clearSilenceProbe ( )
156211 clearAllStreamTimeouts ( )
157212 clearAllStreaming ( )
158213 setConnectionState ( 'disconnected' )
@@ -164,6 +219,7 @@ export function useGatewayChatStream(
164219 if ( ! mountedRef . current ) return
165220
166221 if ( eventSource . readyState === EventSource . CLOSED ) {
222+ clearSilenceProbe ( )
167223 clearAllStreamTimeouts ( )
168224 clearAllStreaming ( )
169225 setConnectionState ( 'disconnected' )
@@ -176,6 +232,7 @@ export function useGatewayChatStream(
176232
177233 eventSource . addEventListener ( 'heartbeat' , ( ) => {
178234 // Keep-alive received, connection is healthy
235+ markActivity ( )
179236 } )
180237
181238 // Chat event handlers
@@ -188,6 +245,7 @@ export function useGatewayChatStream(
188245 sessionKey : string
189246 }
190247 processEvent ( { type : 'chunk' , ...data } )
248+ markActivity ( )
191249 touchStreamTimeout ( data . sessionKey )
192250 onChunkRef . current ?.( data . text , data . sessionKey )
193251 } catch {
@@ -204,6 +262,7 @@ export function useGatewayChatStream(
204262 sessionKey : string
205263 }
206264 processEvent ( { type : 'thinking' , ...data } )
265+ markActivity ( )
207266 touchStreamTimeout ( data . sessionKey )
208267 onThinkingRef . current ?.( data . text , data . sessionKey )
209268 } catch {
@@ -224,6 +283,7 @@ export function useGatewayChatStream(
224283 sessionKey : string
225284 }
226285 processEvent ( { type : 'tool' , ...data , result : data . result } as any )
286+ markActivity ( )
227287 touchStreamTimeout ( data . sessionKey )
228288 if ( data . phase === 'done' || data . phase === 'error' ) {
229289 dispatchChatToolEvent ( CHAT_TOOL_RESULT_EVENT , data )
@@ -256,6 +316,7 @@ export function useGatewayChatStream(
256316 runId : data . runId ,
257317 sessionKey : data . sessionKey ,
258318 } )
319+ markActivity ( )
259320 touchStreamTimeout ( data . sessionKey )
260321 dispatchChatToolEvent ( CHAT_TOOL_CALL_EVENT , {
261322 phase : 'calling' ,
@@ -290,6 +351,7 @@ export function useGatewayChatStream(
290351 runId : data . runId ,
291352 sessionKey : data . sessionKey ,
292353 } )
354+ markActivity ( )
293355 touchStreamTimeout ( data . sessionKey )
294356 dispatchChatToolEvent ( CHAT_TOOL_RESULT_EVENT , {
295357 phase : data . isError || data . error ? 'error' : 'done' ,
@@ -314,6 +376,7 @@ export function useGatewayChatStream(
314376 source ?: string
315377 }
316378 processEvent ( { type : 'user_message' , ...data } )
379+ markActivity ( )
317380 onUserMessageRef . current ?.( data . message , data . source )
318381 } catch {
319382 // Ignore parse errors
@@ -329,6 +392,7 @@ export function useGatewayChatStream(
329392 }
330393 // debug: console.log(`[SSE] message event received: role=${data.message?.role} sessionKey=${data.sessionKey}`)
331394 processEvent ( { type : 'message' , ...data } )
395+ markActivity ( )
332396 } catch {
333397 // Ignore parse errors
334398 }
@@ -348,6 +412,7 @@ export function useGatewayChatStream(
348412 const streamingSnapshot =
349413 useGatewayChatStore . getState ( ) . streamingState . get ( data . sessionKey ) ?? null
350414 processEvent ( { type : 'done' , ...data } )
415+ markActivity ( )
351416 clearStreamTimeout ( data . sessionKey )
352417 dispatchChatStreamDoneEvent ( data )
353418 onDoneRef . current ?.( data . state , data . sessionKey , streamingSnapshot )
@@ -368,6 +433,7 @@ export function useGatewayChatStream(
368433 }
369434 if ( data . state === 'started' && data . sessionKey && data . runId ) {
370435 processEvent ( { type : 'chunk' , text : '' , runId : data . runId , sessionKey : data . sessionKey } )
436+ markActivity ( )
371437 touchStreamTimeout ( data . sessionKey )
372438 }
373439 } catch {
@@ -390,10 +456,13 @@ export function useGatewayChatStream(
390456 processEvent ,
391457 clearAllStreaming ,
392458 clearAllStreamTimeouts ,
459+ clearSilenceProbe ,
393460 clearStreamTimeout ,
394461 dispatchChatStreamDoneEvent ,
395462 dispatchChatToolEvent ,
396463 dispatchSSEDroppedEvent ,
464+ markActivity ,
465+ startSilenceProbe ,
397466 touchStreamTimeout ,
398467 ] )
399468
@@ -413,8 +482,10 @@ export function useGatewayChatStream(
413482 connect ( )
414483 } , delay )
415484 } , [ enabled , connect ] )
485+ scheduleReconnectRef . current = scheduleReconnect
416486
417487 const disconnect = useCallback ( ( ) => {
488+ clearSilenceProbe ( )
418489 clearAllStreamTimeouts ( )
419490
420491 if ( eventSourceRef . current ) {
@@ -429,7 +500,7 @@ export function useGatewayChatStream(
429500
430501 clearAllStreaming ( )
431502 setConnectionState ( 'disconnected' )
432- } , [ clearAllStreaming , clearAllStreamTimeouts , setConnectionState ] )
503+ } , [ clearAllStreaming , clearAllStreamTimeouts , clearSilenceProbe , setConnectionState ] )
433504
434505 const reconnect = useCallback ( ( ) => {
435506 disconnect ( )
0 commit comments