Skip to content

Commit 2c63e74

Browse files
author
outsourc-e
committed
fix: chat auto-update reliability with SSE reconnect backfill
- Backfill message history on SSE reconnect (catches missed messages) - Silence probe: if no activity for 45s, proactively backfill - Activity tracking on all SSE events (message, done, chunk) - Prevents stale chat state requiring manual Ctrl+R refresh - Fixes reconnect destructuring (restored reconnect export)
1 parent aebd348 commit 2c63e74

2 files changed

Lines changed: 93 additions & 1 deletion

File tree

src/hooks/use-gateway-chat-stream.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

3135
export 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()

src/screens/chat/hooks/use-realtime-chat-history.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,31 @@ export function useRealtimeChatHistory({
9898
const completedStreamingTextRef = useRef<string>('')
9999
const completedStreamingThinkingRef = useRef<string>('')
100100
const lastCompactionSignalRef = useRef<string>('')
101+
const isBackfillingRef = useRef(false)
102+
103+
const backfillHistory = useCallback(async () => {
104+
if (!sessionKey || sessionKey === 'new') return
105+
if (isBackfillingRef.current) return
106+
107+
isBackfillingRef.current = true
108+
try {
109+
const key = chatQueryKeys.history(friendlyId, sessionKey)
110+
await queryClient.invalidateQueries({ queryKey: key, exact: true })
111+
await queryClient.refetchQueries({ queryKey: key, exact: true, type: 'active' })
112+
} finally {
113+
isBackfillingRef.current = false
114+
}
115+
}, [friendlyId, queryClient, sessionKey])
101116

102117
const { connectionState, lastError, reconnect } = useGatewayChatStream({
103118
sessionKey: sessionKey === 'new' ? undefined : sessionKey,
104119
enabled: enabled && sessionKey !== 'new',
120+
onReconnect: useCallback(() => {
121+
void backfillHistory()
122+
}, [backfillHistory]),
123+
onSilentTimeout: useCallback((_silentForMs: number) => {
124+
void backfillHistory()
125+
}, [backfillHistory]),
105126
onUserMessage: useCallback(
106127
(message: GatewayMessage, source?: string) => {
107128
// Filter internal system messages (pre-compaction flushes, heartbeat

0 commit comments

Comments
 (0)