33import { useState , useCallback , useRef , useEffect } from 'react'
44import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query'
55import { toast } from 'sonner'
6- import { getApiErrorMessage } from '@/lib/utils/error-handler'
6+ import { getApiErrorMessage , getLogSafeErrorMessage } from '@/lib/utils/error-handler'
77import { useTranslation } from '@/lib/hooks/use-translation'
88import { sourceChatApi } from '@/lib/api/source-chat'
99import { selectMessageId } from '@/lib/utils/source-chat-message'
@@ -36,6 +36,10 @@ export function useSourceChat(sourceId: string) {
3636 // reads that must see the freshest value go through these refs.
3737 const messagesRef = useRef < SourceChatMessage [ ] > ( [ ] )
3838 const currentSessionIdRef = useRef < string | null > ( null )
39+ // The session the local `messages` list currently represents. One list is
40+ // shared by every session, so a send must stop writing into it as soon as the
41+ // list belongs to a session other than the one being streamed.
42+ const messagesSessionRef = useRef < string | null > ( null )
3943 // An explicit Stop still has to adopt an auto-created session; an abort from
4044 // the unmount cleanup must not touch state or the shared cache.
4145 const unmountedRef = useRef ( false )
@@ -97,8 +101,14 @@ export function useSourceChat(sourceId: string) {
97101 useEffect ( ( ) => {
98102 if ( ! currentSession ?. messages ) return
99103 // A send streaming into this session applies the authoritative state from
100- // its own final refetch — an earlier snapshot would drop newer turns.
101- if ( streamingSessionRef . current === currentSession . id ) return
104+ // its own final refetch — an earlier snapshot would drop newer turns. That
105+ // only holds while the list is still this session's: after a switch away
106+ // and back, the list holds another session's messages and must be replaced.
107+ if (
108+ streamingSessionRef . current === currentSession . id &&
109+ messagesSessionRef . current === currentSession . id
110+ ) return
111+ messagesSessionRef . current = currentSession . id
102112 applyMessages ( currentSession . messages )
103113 } , [ currentSession , applyMessages ] )
104114
@@ -149,6 +159,7 @@ export function useSourceChat(sourceId: string) {
149159 queryClient . invalidateQueries ( { queryKey : [ 'sourceChatSessions' , sourceId ] } )
150160 if ( currentSessionId === deletedId ) {
151161 applySessionId ( null )
162+ messagesSessionRef . current = null
152163 applyMessages ( [ ] )
153164 }
154165 toast . success ( t ( 'chat.sessionDeleted' ) )
@@ -203,7 +214,7 @@ export function useSourceChat(sourceId: string) {
203214 sessionId = await createPromise
204215 } catch ( err : unknown ) {
205216 const error = err as { response ?: { data ?: { detail ?: string } } , message ?: string } ;
206- console . error ( 'Failed to create chat session:' , error )
217+ console . error ( 'Failed to create chat session:' , getLogSafeErrorMessage ( err ) )
207218 toast . error ( getApiErrorMessage ( error . response ?. data ?. detail || error . message , ( key ) => t ( key ) , 'apiErrors.failedToCreateSession' ) )
208219 releaseSend ( )
209220 return
@@ -238,6 +249,15 @@ export function useSourceChat(sourceId: string) {
238249
239250 const streamSessionId = sessionId
240251 streamingSessionRef . current = streamSessionId
252+ messagesSessionRef . current = streamSessionId
253+
254+ // The turn belongs to the session it was composed in, so it is still sent
255+ // after the user navigates away — but the shared list then shows another
256+ // session and must not receive this stream's messages. The backend persists
257+ // the exchange, so switching back reloads it from the checkpoint.
258+ const ownsMessages = ( ) =>
259+ currentSessionIdRef . current === streamSessionId &&
260+ messagesSessionRef . current === streamSessionId
241261
242262 // `messages` only holds authoritative state once the session query has
243263 // resolved, and the composer is gated on `isStreaming` alone — a send can
@@ -250,10 +270,12 @@ export function useSourceChat(sourceId: string) {
250270 const hydrated = await fetchSession ( streamSessionId )
251271 if ( hydrated ?. messages ) {
252272 knownMessages = hydrated . messages
253- applyMessages ( knownMessages )
273+ if ( ownsMessages ( ) ) {
274+ applyMessages ( knownMessages )
275+ }
254276 }
255277 } catch ( err ) {
256- console . error ( 'Error loading chat session before send:' , err )
278+ console . error ( 'Error loading chat session before send:' , getLogSafeErrorMessage ( err ) )
257279 }
258280 if ( isSuperseded ( ) ) {
259281 releaseSend ( )
@@ -278,9 +300,11 @@ export function useSourceChat(sourceId: string) {
278300 content : message ,
279301 timestamp : new Date ( ) . toISOString ( )
280302 }
281- applyMessages ( prev =>
282- prev . some ( m => m . id === messageId ) ? prev : [ ...prev , userMessage ]
283- )
303+ if ( ownsMessages ( ) ) {
304+ applyMessages ( prev =>
305+ prev . some ( m => m . id === messageId ) ? prev : [ ...prev , userMessage ]
306+ )
307+ }
284308
285309 try {
286310 const response = await sourceChatApi . sendMessage ( sourceId , streamSessionId , {
@@ -317,22 +341,25 @@ export function useSourceChat(sourceId: string) {
317341 const data = JSON . parse ( jsonStr )
318342
319343 if ( data . type === 'ai_message' ) {
320- // Create AI message on first content chunk to avoid empty bubble
344+ // Accumulate regardless of what is on screen, so a switch away
345+ // and back re-attaches the full answer rather than a fragment.
321346 if ( ! aiMessage ) {
347+ // Created on the first content chunk to avoid an empty bubble.
322348 aiMessage = {
323349 id : `ai-${ Date . now ( ) } ` ,
324350 type : 'ai' ,
325351 content : data . content || '' ,
326352 timestamp : new Date ( ) . toISOString ( )
327353 }
328- applyMessages ( prev => [ ...prev , aiMessage ! ] )
329354 } else {
330355 aiMessage . content += data . content || ''
356+ }
357+ if ( ownsMessages ( ) ) {
358+ const streamed = { ...aiMessage }
331359 applyMessages ( prev =>
332- prev . map ( msg => msg . id === aiMessage ! . id
333- ? { ...msg , content : aiMessage ! . content }
334- : msg
335- )
360+ prev . some ( msg => msg . id === streamed . id )
361+ ? prev . map ( msg => msg . id === streamed . id ? streamed : msg )
362+ : [ ...prev , streamed ]
336363 )
337364 }
338365 } else if ( data . type === 'context_indicators' ) {
@@ -357,11 +384,11 @@ export function useSourceChat(sourceId: string) {
357384 }
358385 // Drop only a bubble we added in this send — a retry reuses a persisted id
359386 // that must stay visible until the refetch completes.
360- if ( ! isSuperseded ( ) && ! messageAlreadyPresent ) {
387+ if ( ! isSuperseded ( ) && ! messageAlreadyPresent && ownsMessages ( ) ) {
361388 applyMessages ( prev => prev . filter ( m => m . id !== messageId ) )
362389 }
363390 const error = err as { response ?: { data ?: { detail ?: string } } , message ?: string } ;
364- console . error ( 'Error sending message:' , error )
391+ console . error ( 'Error sending message:' , getLogSafeErrorMessage ( err ) )
365392 toast . error ( getApiErrorMessage ( error . response ?. data ?. detail || error . message , ( key ) => t ( key ) , 'apiErrors.failedToSendMessage' ) )
366393 } finally {
367394 // A send replaced by a newer one must not clear the newer stream's loading
@@ -377,15 +404,11 @@ export function useSourceChat(sourceId: string) {
377404 try {
378405 const persisted = await fetchSession ( streamSessionId )
379406 // A newer send or a session switch owns the messages now.
380- if (
381- isLatestSend ( ) &&
382- currentSessionIdRef . current === streamSessionId &&
383- persisted ?. messages
384- ) {
407+ if ( isLatestSend ( ) && ownsMessages ( ) && persisted ?. messages ) {
385408 applyMessages ( persisted . messages )
386409 }
387410 } catch ( err ) {
388- console . error ( 'Error refreshing chat session:' , err )
411+ console . error ( 'Error refreshing chat session:' , getLogSafeErrorMessage ( err ) )
389412 }
390413 }
391414 if ( isLatestSend ( ) ) {
0 commit comments