fix(chat): finalize terminal lifecycle to prevent stuck processing/thinking UI (#483)

This commit is contained in:
Haileyesus
2026-03-04 22:49:24 +03:00
committed by GitHub
parent 2320e1d74b
commit 0590c5c178
3 changed files with 75 additions and 26 deletions

View File

@@ -551,7 +551,7 @@ export function useChatComposerState({
}; };
setChatMessages((previous) => [...previous, userMessage]); setChatMessages((previous) => [...previous, userMessage]);
setIsLoading(true); setIsLoading(true); // Processing banner starts
setCanAbortSession(true); setCanAbortSession(true);
setClaudeStatus({ setClaudeStatus({
text: 'Processing', text: 'Processing',

View File

@@ -134,9 +134,10 @@ export function useChatRealtimeHandlers({
latestMessage.data && typeof latestMessage.data === 'object' latestMessage.data && typeof latestMessage.data === 'object'
? (latestMessage.data as Record<string, any>) ? (latestMessage.data as Record<string, any>)
: null; : null;
const messageType = String(latestMessage.type);
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type)); const isGlobalMessage = globalMessageTypes.includes(messageType);
const lifecycleMessageTypes = new Set([ const lifecycleMessageTypes = new Set([
'claude-complete', 'claude-complete',
'codex-complete', 'codex-complete',
@@ -146,6 +147,7 @@ export function useChatRealtimeHandlers({
'cursor-error', 'cursor-error',
'codex-error', 'codex-error',
'gemini-error', 'gemini-error',
'error',
]); ]);
const isClaudeSystemInit = const isClaudeSystemInit =
@@ -168,9 +170,12 @@ export function useChatRealtimeHandlers({
const activeViewSessionId = const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const hasPendingUnboundSession =
Boolean(pendingViewSessionRef.current) && !pendingViewSessionRef.current?.sessionId;
const isSystemInitForView = const isSystemInitForView =
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView); const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
const isLifecycleMessage = lifecycleMessageTypes.has(messageType);
const isUnscopedError = const isUnscopedError =
!latestMessage.sessionId && !latestMessage.sessionId &&
pendingViewSessionRef.current && pendingViewSessionRef.current &&
@@ -201,6 +206,30 @@ export function useChatRealtimeHandlers({
setClaudeStatus(null); setClaudeStatus(null);
}; };
const clearPendingViewSession = (resolvedSessionId?: string | null) => {
const pendingSession = pendingViewSessionRef.current;
if (!pendingSession) {
return;
}
// If the in-view request never received a concrete session ID (or this terminal event
// resolves the same pending session), clear it to avoid stale "in-flight" UI state.
if (!pendingSession.sessionId || !resolvedSessionId || pendingSession.sessionId === resolvedSessionId) {
pendingViewSessionRef.current = null;
}
};
const flushStreamingState = () => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const pendingChunk = streamBufferRef.current;
streamBufferRef.current = '';
appendStreamingChunk(setChatMessages, pendingChunk, false);
finalizeStreamingMessage(setChatMessages);
};
const markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => { const markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => {
const normalizedSessionIds = collectSessionIds(...sessionIds); const normalizedSessionIds = collectSessionIds(...sessionIds);
normalizedSessionIds.forEach((sessionId) => { normalizedSessionIds.forEach((sessionId) => {
@@ -209,25 +238,46 @@ export function useChatRealtimeHandlers({
}); });
}; };
const finalizeLifecycleForCurrentView = (...sessionIds: Array<string | null | undefined>) => {
const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const resolvedSessionIds = collectSessionIds(...sessionIds, pendingSessionId, pendingViewSessionRef.current?.sessionId);
const resolvedPrimarySessionId = resolvedSessionIds[0] || null;
flushStreamingState();
clearLoadingIndicators();
markSessionsAsCompleted(...resolvedSessionIds);
setPendingPermissionRequests([]);
clearPendingViewSession(resolvedPrimarySessionId);
};
if (!shouldBypassSessionFilter) { if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) { if (!activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { if (latestMessage.sessionId && isLifecycleMessage && !hasPendingUnboundSession) {
handleBackgroundLifecycle(latestMessage.sessionId); handleBackgroundLifecycle(latestMessage.sessionId);
return;
} }
if (!isUnscopedError) { if (!isUnscopedError && !hasPendingUnboundSession) {
return; return;
} }
} }
if (!latestMessage.sessionId && !isUnscopedError) { if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) {
return; return;
} }
if (latestMessage.sessionId !== activeViewSessionId) { if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { const shouldTreatAsPendingViewLifecycle =
handleBackgroundLifecycle(latestMessage.sessionId); !activeViewSessionId &&
hasPendingUnboundSession &&
latestMessage.sessionId &&
isLifecycleMessage;
if (!shouldTreatAsPendingViewLifecycle) {
if (latestMessage.sessionId && isLifecycleMessage) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
} }
return;
} }
} }
@@ -545,6 +595,7 @@ export function useChatRealtimeHandlers({
break; break;
case 'claude-error': case 'claude-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -604,6 +655,7 @@ export function useChatRealtimeHandlers({
break; break;
case 'cursor-error': case 'cursor-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -618,8 +670,7 @@ export function useChatRealtimeHandlers({
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
cursorCompletedSessionId, cursorCompletedSessionId,
currentSessionId, currentSessionId,
selectedSession?.id, selectedSession?.id,
@@ -701,8 +752,7 @@ export function useChatRealtimeHandlers({
const completedSessionId = const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId; latestMessage.sessionId || currentSessionId || pendingSessionId;
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
completedSessionId, completedSessionId,
currentSessionId, currentSessionId,
selectedSession?.id, selectedSession?.id,
@@ -718,7 +768,6 @@ export function useChatRealtimeHandlers({
if (selectedProject && latestMessage.exitCode === 0) { if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
} }
setPendingPermissionRequests([]);
break; break;
} }
@@ -836,13 +885,11 @@ export function useChatRealtimeHandlers({
} }
if (codexData.type === 'turn_complete') { if (codexData.type === 'turn_complete') {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
} }
if (codexData.type === 'turn_failed') { if (codexData.type === 'turn_failed') {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -861,8 +908,7 @@ export function useChatRealtimeHandlers({
const codexCompletedSessionId = const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId; latestMessage.sessionId || currentSessionId || codexPendingSessionId;
clearLoadingIndicators(); finalizeLifecycleForCurrentView(
markSessionsAsCompleted(
codexCompletedSessionId, codexCompletedSessionId,
codexActualSessionId, codexActualSessionId,
currentSessionId, currentSessionId,
@@ -886,8 +932,7 @@ export function useChatRealtimeHandlers({
} }
case 'codex-error': case 'codex-error':
setIsLoading(false); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setCanAbortSession(false);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -937,8 +982,7 @@ export function useChatRealtimeHandlers({
} }
case 'gemini-error': case 'gemini-error':
setIsLoading(false); finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setCanAbortSession(false);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -990,13 +1034,11 @@ export function useChatRealtimeHandlers({
const abortSucceeded = latestMessage.success !== false; const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded) { if (abortSucceeded) {
clearLoadingIndicators(); finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
} }
setPendingPermissionRequests([]);
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -1093,6 +1135,12 @@ export function useChatRealtimeHandlers({
break; break;
} }
case 'error':
// Generic backend failure (e.g., provider process failed before a provider-specific
// completion event was emitted). Treat it as terminal for current view lifecycle.
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
break;
default: default:
break; break;
} }

View File

@@ -67,6 +67,7 @@ const useWebSocketProviderState = (): WebSocketContextType => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
setLatestMessage(data); setLatestMessage(data);
console.log('--->Received WebSocket message:', data);
} catch (error) { } catch (error) {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error);
} }