From fd5cced67fa990ebacaf897ae64b3e392cab1d6f Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Wed, 11 Feb 2026 17:24:43 +0300 Subject: [PATCH] fix(chat): clear stuck loading state across realtime lifecycle events The chat UI could remain in a stale "Thinking/Processing" state when session IDs did not line up exactly between view state (`currentSessionId`), selected route session, pending session IDs, and provider lifecycle events. This was most visible with Codex completion/abort flows, but the same mismatch risk existed in shared handlers. Unify lifecycle cleanup behavior in realtime handlers and make processing tracking key off the active viewed session identity. Changes: - src/hooks/chat/useChatRealtimeHandlers.ts - src/components/ChatInterface.tsx - src/hooks/chat/useChatSessionState.ts What changed: - Added shared helpers in realtime handling: - `collectSessionIds(...)` to normalize and dedupe candidate session IDs. - `clearLoadingIndicators()` to consistently clear `isLoading`, abort UI, and status. - `markSessionsAsCompleted(...)` to consistently notify inactive/not-processing state. - Updated lifecycle branches to use shared cleanup logic: - `cursor-result` - `claude-complete` - `codex-response` (`turn_complete` and `turn_failed`) - `codex-complete` - `session-aborted` - Expanded completion/abort cleanup to include all relevant session IDs (`latestMessage.sessionId`, `currentSessionId`, `selectedSession?.id`, `pendingSessionId`, and Codex `actualSessionId` when present). - Switched processing-session marking in `ChatInterface` to use `selectedSession?.id || currentSessionId` instead of `currentSessionId` alone. - Switched processing-session rehydration in `useChatSessionState` to use the same active-view session identity fallback. Result: - Prevents stale loading indicators after completion/abort when IDs differ. - Keeps processing session bookkeeping aligned with the currently viewed session. - Reduces provider-specific drift by using one lifecycle cleanup pattern. --- src/components/ChatInterface.tsx | 7 +- src/hooks/chat/useChatRealtimeHandlers.ts | 188 +++++++++++----------- src/hooks/chat/useChatSessionState.ts | 7 +- 3 files changed, 104 insertions(+), 98 deletions(-) diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index c87238f..70554e5 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -232,10 +232,11 @@ function ChatInterface({ }, [canAbortSession, handleAbortSession, isLoading]); useEffect(() => { - if (currentSessionId && isLoading && onSessionProcessing) { - onSessionProcessing(currentSessionId); + const processingSessionId = selectedSession?.id || currentSessionId; + if (processingSessionId && isLoading && onSessionProcessing) { + onSessionProcessing(processingSessionId); } - }, [currentSessionId, isLoading, onSessionProcessing]); + }, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]); useEffect(() => { return () => { diff --git a/src/hooks/chat/useChatRealtimeHandlers.ts b/src/hooks/chat/useChatRealtimeHandlers.ts index 6e875fb..ae629d1 100644 --- a/src/hooks/chat/useChatRealtimeHandlers.ts +++ b/src/hooks/chat/useChatRealtimeHandlers.ts @@ -167,6 +167,27 @@ export function useChatRealtimeHandlers({ onSessionNotProcessing?.(sessionId); }; + const collectSessionIds = (...sessionIds: Array) => + Array.from( + new Set( + sessionIds.filter((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0), + ), + ); + + const clearLoadingIndicators = () => { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + }; + + const markSessionsAsCompleted = (...sessionIds: Array) => { + const normalizedSessionIds = collectSessionIds(...sessionIds); + normalizedSessionIds.forEach((sessionId) => { + onSessionInactive?.(sessionId); + onSessionNotProcessing?.(sessionId); + }); + }; + if (!shouldBypassSessionFilter) { if (!activeViewSessionId) { if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { @@ -516,56 +537,51 @@ export function useChatRealtimeHandlers({ case 'cursor-result': { const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - - if (cursorCompletedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (cursorCompletedSessionId) { - onSessionInactive?.(cursorCompletedSessionId); - onSessionNotProcessing?.(cursorCompletedSessionId); - } - - if (cursorCompletedSessionId === currentSessionId) { - try { - const resultData = latestMessage.data || {}; - const textResult = typeof resultData.result === 'string' ? resultData.result : ''; - - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; - - setChatMessages((previous) => { - const updated = [...previous]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - const finalContent = - textResult && textResult.trim() - ? textResult - : `${last.content || ''}${pendingChunk || ''}`; - last.content = finalContent; - last.isStreaming = false; - } else if (textResult && textResult.trim()) { - updated.push({ - type: resultData.is_error ? 'error' : 'assistant', - content: textResult, - timestamp: new Date(), - isStreaming: false, - }); - } - return updated; - }); - } catch (error) { - console.warn('Error handling cursor-result message:', error); - } - } - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); + + clearLoadingIndicators(); + markSessionsAsCompleted( + cursorCompletedSessionId, + currentSessionId, + selectedSession?.id, + pendingCursorSessionId, + ); + + try { + const resultData = latestMessage.data || {}; + const textResult = typeof resultData.result === 'string' ? resultData.result : ''; + + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; + } + const pendingChunk = streamBufferRef.current; + streamBufferRef.current = ''; + + setChatMessages((previous) => { + const updated = [...previous]; + const last = updated[updated.length - 1]; + if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { + const finalContent = + textResult && textResult.trim() + ? textResult + : `${last.content || ''}${pendingChunk || ''}`; + last.content = finalContent; + last.isStreaming = false; + } else if (textResult && textResult.trim()) { + updated.push({ + type: resultData.is_error ? 'error' : 'assistant', + content: textResult, + timestamp: new Date(), + isStreaming: false, + }); + } + return updated; + }); + } catch (error) { + console.warn('Error handling cursor-result message:', error); + } + if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { setCurrentSessionId(cursorCompletedSessionId); sessionStorage.removeItem('pendingSessionId'); @@ -601,21 +617,18 @@ export function useChatRealtimeHandlers({ break; case 'claude-complete': { - const completedSessionId = - latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - if (completedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (completedSessionId) { - onSessionInactive?.(completedSessionId); - onSessionNotProcessing?.(completedSessionId); - } - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); + const completedSessionId = + latestMessage.sessionId || currentSessionId || pendingSessionId; + + clearLoadingIndicators(); + markSessionsAsCompleted( + completedSessionId, + currentSessionId, + selectedSession?.id, + pendingSessionId, + ); + if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { setCurrentSessionId(pendingSessionId); sessionStorage.removeItem('pendingSessionId'); @@ -743,11 +756,13 @@ export function useChatRealtimeHandlers({ } if (codexData.type === 'turn_complete') { - setIsLoading(false); + clearLoadingIndicators(); + markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); } if (codexData.type === 'turn_failed') { - setIsLoading(false); + clearLoadingIndicators(); + markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); setChatMessages((previous) => [ ...previous, { @@ -761,22 +776,20 @@ export function useChatRealtimeHandlers({ } case 'codex-complete': { - const codexCompletedSessionId = - latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - if (codexCompletedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (codexCompletedSessionId) { - onSessionInactive?.(codexCompletedSessionId); - onSessionNotProcessing?.(codexCompletedSessionId); - } - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; + const codexCompletedSessionId = + latestMessage.sessionId || currentSessionId || codexPendingSessionId; + + clearLoadingIndicators(); + markSessionsAsCompleted( + codexCompletedSessionId, + codexActualSessionId, + currentSessionId, + selectedSession?.id, + codexPendingSessionId, + ); + if (codexPendingSessionId && !currentSessionId) { setCurrentSessionId(codexActualSessionId); setIsSystemSessionChange(true); @@ -807,23 +820,14 @@ export function useChatRealtimeHandlers({ break; case 'session-aborted': { + const pendingSessionId = + typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; const abortedSessionId = latestMessage.sessionId || currentSessionId; const abortSucceeded = latestMessage.success !== false; - if (abortSucceeded && abortedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (abortSucceeded && abortedSessionId) { - onSessionInactive?.(abortedSessionId); - onSessionNotProcessing?.(abortedSessionId); - } - if (abortSucceeded) { - const pendingSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; + clearLoadingIndicators(); + markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { sessionStorage.removeItem('pendingSessionId'); } diff --git a/src/hooks/chat/useChatSessionState.ts b/src/hooks/chat/useChatSessionState.ts index 21891b3..6e2f542 100644 --- a/src/hooks/chat/useChatSessionState.ts +++ b/src/hooks/chat/useChatSessionState.ts @@ -539,16 +539,17 @@ export function useChatSessionState({ }, [handleScroll]); useEffect(() => { - if (!currentSessionId || !processingSessions) { + const activeViewSessionId = selectedSession?.id || currentSessionId; + if (!activeViewSessionId || !processingSessions) { return; } - const shouldBeProcessing = processingSessions.has(currentSessionId); + const shouldBeProcessing = processingSessions.has(activeViewSessionId); if (shouldBeProcessing && !isLoading) { setIsLoading(true); setCanAbortSession(true); } - }, [currentSessionId, isLoading, processingSessions]); + }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); const loadEarlierMessages = useCallback(() => { setVisibleMessageCount((previousCount) => previousCount + 100);