mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 12:47:33 +00:00
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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user