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]);
setIsLoading(true);
setIsLoading(true); // Processing banner starts
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',

View File

@@ -134,9 +134,10 @@ export function useChatRealtimeHandlers({
latestMessage.data && typeof latestMessage.data === 'object'
? (latestMessage.data as Record<string, any>)
: null;
const messageType = String(latestMessage.type);
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([
'claude-complete',
'codex-complete',
@@ -146,6 +147,7 @@ export function useChatRealtimeHandlers({
'cursor-error',
'codex-error',
'gemini-error',
'error',
]);
const isClaudeSystemInit =
@@ -168,9 +170,12 @@ export function useChatRealtimeHandlers({
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const hasPendingUnboundSession =
Boolean(pendingViewSessionRef.current) && !pendingViewSessionRef.current?.sessionId;
const isSystemInitForView =
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
const isLifecycleMessage = lifecycleMessageTypes.has(messageType);
const isUnscopedError =
!latestMessage.sessionId &&
pendingViewSessionRef.current &&
@@ -201,6 +206,30 @@ export function useChatRealtimeHandlers({
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 normalizedSessionIds = collectSessionIds(...sessionIds);
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 (!activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
if (latestMessage.sessionId && isLifecycleMessage && !hasPendingUnboundSession) {
handleBackgroundLifecycle(latestMessage.sessionId);
return;
}
if (!isUnscopedError) {
if (!isUnscopedError && !hasPendingUnboundSession) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError) {
if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) {
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
const shouldTreatAsPendingViewLifecycle =
!activeViewSessionId &&
hasPendingUnboundSession &&
latestMessage.sessionId &&
isLifecycleMessage;
if (!shouldTreatAsPendingViewLifecycle) {
if (latestMessage.sessionId && isLifecycleMessage) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
}
return;
}
}
@@ -545,6 +595,7 @@ export function useChatRealtimeHandlers({
break;
case 'claude-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -604,6 +655,7 @@ export function useChatRealtimeHandlers({
break;
case 'cursor-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -618,8 +670,7 @@ export function useChatRealtimeHandlers({
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
clearLoadingIndicators();
markSessionsAsCompleted(
finalizeLifecycleForCurrentView(
cursorCompletedSessionId,
currentSessionId,
selectedSession?.id,
@@ -701,8 +752,7 @@ export function useChatRealtimeHandlers({
const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
finalizeLifecycleForCurrentView(
completedSessionId,
currentSessionId,
selectedSession?.id,
@@ -718,7 +768,6 @@ export function useChatRealtimeHandlers({
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
setPendingPermissionRequests([]);
break;
}
@@ -836,13 +885,11 @@ export function useChatRealtimeHandlers({
}
if (codexData.type === 'turn_complete') {
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
}
if (codexData.type === 'turn_failed') {
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -861,8 +908,7 @@ export function useChatRealtimeHandlers({
const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
finalizeLifecycleForCurrentView(
codexCompletedSessionId,
codexActualSessionId,
currentSessionId,
@@ -886,8 +932,7 @@ export function useChatRealtimeHandlers({
}
case 'codex-error':
setIsLoading(false);
setCanAbortSession(false);
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -937,8 +982,7 @@ export function useChatRealtimeHandlers({
}
case 'gemini-error':
setIsLoading(false);
setCanAbortSession(false);
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
@@ -990,13 +1034,11 @@ export function useChatRealtimeHandlers({
const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded) {
clearLoadingIndicators();
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId');
}
setPendingPermissionRequests([]);
setChatMessages((previous) => [
...previous,
{
@@ -1093,6 +1135,12 @@ export function useChatRealtimeHandlers({
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:
break;
}

View File

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