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.
This commit is contained in:
Haileyesus
2026-02-11 17:24:43 +03:00
parent 9524313363
commit fd5cced67f
3 changed files with 104 additions and 98 deletions

View File

@@ -232,10 +232,11 @@ function ChatInterface({
}, [canAbortSession, handleAbortSession, isLoading]); }, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => { useEffect(() => {
if (currentSessionId && isLoading && onSessionProcessing) { const processingSessionId = selectedSession?.id || currentSessionId;
onSessionProcessing(currentSessionId); if (processingSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(processingSessionId);
} }
}, [currentSessionId, isLoading, onSessionProcessing]); }, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@@ -167,6 +167,27 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing?.(sessionId); onSessionNotProcessing?.(sessionId);
}; };
const collectSessionIds = (...sessionIds: Array<string | null | undefined>) =>
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<string | null | undefined>) => {
const normalizedSessionIds = collectSessionIds(...sessionIds);
normalizedSessionIds.forEach((sessionId) => {
onSessionInactive?.(sessionId);
onSessionNotProcessing?.(sessionId);
});
};
if (!shouldBypassSessionFilter) { if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) { if (!activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
@@ -516,56 +537,51 @@ export function useChatRealtimeHandlers({
case 'cursor-result': { case 'cursor-result': {
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; 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'); 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) { if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
setCurrentSessionId(cursorCompletedSessionId); setCurrentSessionId(cursorCompletedSessionId);
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
@@ -601,21 +617,18 @@ export function useChatRealtimeHandlers({
break; break;
case 'claude-complete': { 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 pendingSessionId = sessionStorage.getItem('pendingSessionId');
const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
completedSessionId,
currentSessionId,
selectedSession?.id,
pendingSessionId,
);
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
setCurrentSessionId(pendingSessionId); setCurrentSessionId(pendingSessionId);
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
@@ -743,11 +756,13 @@ export function useChatRealtimeHandlers({
} }
if (codexData.type === 'turn_complete') { if (codexData.type === 'turn_complete') {
setIsLoading(false); clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
} }
if (codexData.type === 'turn_failed') { if (codexData.type === 'turn_failed') {
setIsLoading(false); clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -761,22 +776,20 @@ export function useChatRealtimeHandlers({
} }
case 'codex-complete': { 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 codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
codexCompletedSessionId,
codexActualSessionId,
currentSessionId,
selectedSession?.id,
codexPendingSessionId,
);
if (codexPendingSessionId && !currentSessionId) { if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexActualSessionId); setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true); setIsSystemSessionChange(true);
@@ -807,23 +820,14 @@ export function useChatRealtimeHandlers({
break; break;
case 'session-aborted': { case 'session-aborted': {
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const abortedSessionId = latestMessage.sessionId || currentSessionId; const abortedSessionId = latestMessage.sessionId || currentSessionId;
const abortSucceeded = latestMessage.success !== false; const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded && abortedSessionId === currentSessionId) {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
}
if (abortSucceeded && abortedSessionId) {
onSessionInactive?.(abortedSessionId);
onSessionNotProcessing?.(abortedSessionId);
}
if (abortSucceeded) { if (abortSucceeded) {
const pendingSessionId = clearLoadingIndicators();
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
} }

View File

@@ -539,16 +539,17 @@ export function useChatSessionState({
}, [handleScroll]); }, [handleScroll]);
useEffect(() => { useEffect(() => {
if (!currentSessionId || !processingSessions) { const activeViewSessionId = selectedSession?.id || currentSessionId;
if (!activeViewSessionId || !processingSessions) {
return; return;
} }
const shouldBeProcessing = processingSessions.has(currentSessionId); const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) { if (shouldBeProcessing && !isLoading) {
setIsLoading(true); setIsLoading(true);
setCanAbortSession(true); setCanAbortSession(true);
} }
}, [currentSessionId, isLoading, processingSessions]); }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
const loadEarlierMessages = useCallback(() => { const loadEarlierMessages = useCallback(() => {
setVisibleMessageCount((previousCount) => previousCount + 100); setVisibleMessageCount((previousCount) => previousCount + 100);