Files
claudecodeui/src/components/chat/hooks/useChatRealtimeHandlers.ts
Haileyesus 53af032d88 fix(cursor-chat): stabilize first-run UX and clean cursor message rendering
Fix three Cursor chat regressions observed on first message runs:

1. Full-screen UI refresh/flicker after first response.
2. Internal wrapper tags rendered in user messages.
3. Duplicate assistant message on response finalization.

Root causes
- Project refresh from chat completion used the global loading path,
  toggling app-level loading UI.
- Cursor history conversion rendered raw internal wrapper payloads
  as user-visible message text.
- Cursor response handling could finalize through overlapping stream/
  result paths, and stdout chunk parsing could split JSON lines.

Changes
- Added non-blocking project refresh plumbing for chat/session flows.
- Introduced fetch options in useProjectsState (showLoadingState flag).
- Added refreshProjectsSilently() to update metadata without global loading UI.
- Wired window.refreshProjects to refreshProjectsSilently in AppContent.

- Added Cursor user-message sanitization during history conversion.
- Added extractCursorUserQuery() to keep only <user_query> payload.
- Added sanitizeCursorUserMessageText() to strip internal wrappers:
  <user_info>, <agent_skills>, <available_skills>,
  <environment_context>, <environment_info>.
- Applied sanitization only for role === 'user' in
  convertCursorSessionMessages().

- Hardened Cursor backend stream parsing and finalization.
- Added line-buffered stdout parser for chunk-split JSON payloads.
- Flushed trailing unterminated stdout line on process close.
- Removed redundant content_block_stop emission on Cursor result.

- Added frontend duplicate guard in cursor-result handling.
- Skips a second assistant bubble when final result text equals
  already-rendered streamed content.

Code comments
- Added focused comments describing silent refresh behavior,
  tag stripping rationale, duplicate guard behavior, and line buffering.

Validation
- ESLint passes for touched files.
- Production build succeeds.

Files
- server/cursor-cli.js
- src/components/app/AppContent.tsx
- src/components/chat/hooks/useChatRealtimeHandlers.ts
- src/components/chat/utils/messageTransforms.ts
- src/hooks/useProjectsState.ts
2026-03-10 23:22:24 +03:00

1182 lines
40 KiB
TypeScript

import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
import { safeLocalStorage } from '../utils/chatStorage';
import type { ChatMessage, PendingPermissionRequest } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
type LatestChatMessage = {
type?: string;
data?: any;
sessionId?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: string;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
provider: SessionProvider;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setIsSystemSessionChange: (isSystemSessionChange: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamBufferRef: MutableRefObject<string>;
streamTimerRef: MutableRefObject<number | null>;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
}
const appendStreamingChunk = (
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>,
chunk: string,
newline = false,
) => {
if (!chunk) {
return;
}
setChatMessages((previous) => {
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const nextContent = newline
? last.content
? `${last.content}\n${chunk}`
: chunk
: `${last.content || ''}${chunk}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: nextContent };
} else {
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
}
return updated;
});
};
const finalizeStreamingMessage = (setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>) => {
setChatMessages((previous) => {
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
if (last && last.type === 'assistant' && last.isStreaming) {
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, isStreaming: false };
}
return updated;
});
};
export function useChatRealtimeHandlers({
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
pendingViewSessionRef,
streamBufferRef,
streamTimerRef,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
if (!latestMessage) {
return;
}
// Guard against duplicate processing when dependency updates occur without a new message object.
if (lastProcessedMessageRef.current === latestMessage) {
return;
}
lastProcessedMessageRef.current = latestMessage;
const messageData = latestMessage.data?.message || latestMessage.data;
const structuredMessageData =
messageData && typeof messageData === 'object' ? (messageData as Record<string, any>) : null;
const rawStructuredData =
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(messageType);
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
'cursor-result',
'session-aborted',
'claude-error',
'cursor-error',
'codex-error',
'gemini-error',
'error',
]);
const isClaudeSystemInit =
latestMessage.type === 'claude-response' &&
structuredMessageData &&
structuredMessageData.type === 'system' &&
structuredMessageData.subtype === 'init';
const isCursorSystemInit =
latestMessage.type === 'cursor-system' &&
rawStructuredData &&
rawStructuredData.type === 'system' &&
rawStructuredData.subtype === 'init';
const systemInitSessionId = isClaudeSystemInit
? structuredMessageData?.session_id
: isCursorSystemInit
? rawStructuredData?.session_id
: null;
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 &&
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' ||
latestMessage.type === 'cursor-error' ||
latestMessage.type === 'codex-error' ||
latestMessage.type === 'gemini-error');
const handleBackgroundLifecycle = (sessionId?: string) => {
if (!sessionId) {
return;
}
onSessionInactive?.(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 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) => {
onSessionInactive?.(sessionId);
onSessionNotProcessing?.(sessionId);
});
};
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 && isLifecycleMessage && !hasPendingUnboundSession) {
handleBackgroundLifecycle(latestMessage.sessionId);
return;
}
if (!isUnscopedError && !hasPendingUnboundSession) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) {
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
const shouldTreatAsPendingViewLifecycle =
!activeViewSessionId &&
hasPendingUnboundSession &&
latestMessage.sessionId &&
isLifecycleMessage;
if (!shouldTreatAsPendingViewLifecycle) {
if (latestMessage.sessionId && isLifecycleMessage) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
return;
}
}
}
switch (latestMessage.type) {
case 'session-created':
if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
}
setIsSystemSessionChange(true);
onReplaceTemporarySession?.(latestMessage.sessionId);
setPendingPermissionRequests((previous) =>
previous.map((request) =>
request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId },
),
);
}
break;
case 'token-budget':
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
}
break;
case 'claude-response': {
if (messageData && typeof messageData === 'object' && messageData.type) {
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
const decodedText = decodeHtmlEntities(messageData.delta.text);
streamBufferRef.current += decodedText;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, false);
}, 100);
}
return;
}
if (messageData.type === 'content_block_stop') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
appendStreamingChunk(setChatMessages, chunk, false);
finalizeStreamingMessage(setChatMessages);
return;
}
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
currentSessionId &&
structuredMessageData.session_id !== currentSessionId &&
isSystemInitForView
) {
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
!currentSessionId &&
isSystemInitForView
) {
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
currentSessionId &&
structuredMessageData.session_id === currentSessionId &&
isSystemInitForView
) {
return;
}
if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type === 'tool_use') {
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
// Check if this is a child tool from a subagent
if (parentToolUseId) {
setChatMessages((previous) =>
previous.map((message) => {
if (message.toolId === parentToolUseId && message.isSubagentContainer) {
const childTool = {
toolId: part.id,
toolName: part.name,
toolInput: part.input,
toolResult: null,
timestamp: new Date(),
};
const existingChildren = message.subagentState?.childTools || [];
return {
...message,
subagentState: {
childTools: [...existingChildren, childTool],
currentToolIndex: existingChildren.length,
isComplete: false,
},
};
}
return message;
}),
);
return;
}
// Check if this is a Task tool (subagent container)
const isSubagentContainer = part.name === 'Task';
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: part.name,
toolInput,
toolId: part.id,
toolResult: null,
isSubagentContainer,
subagentState: isSubagentContainer
? { childTools: [], currentToolIndex: -1, isComplete: false }
: undefined,
},
]);
return;
}
if (part.type === 'text' && part.text?.trim()) {
let content = decodeHtmlEntities(part.text);
content = formatUsageLimitText(content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
});
} else if (structuredMessageData && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) {
let content = decodeHtmlEntities(structuredMessageData.content);
content = formatUsageLimitText(content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
}
setChatMessages((previous) =>
previous.map((message) => {
// Handle child tool results (route to parent's subagentState)
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
return {
...message,
subagentState: {
...message.subagentState!,
childTools: message.subagentState!.childTools.map((child) => {
if (child.toolId === part.tool_use_id) {
return {
...child,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
}
return child;
}),
},
};
}
// Handle normal tool results (including parent Task tool completion)
if (message.isToolUse && message.toolId === part.tool_use_id) {
const result = {
...message,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
// Mark subagent as complete when parent Task receives its result
if (message.isSubagentContainer && message.subagentState) {
result.subagentState = {
...message.subagentState,
isComplete: true,
};
}
return result;
}
return message;
}),
);
});
}
break;
}
case 'claude-output': {
const cleaned = String(latestMessage.data || '');
if (cleaned.trim()) {
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, true);
}, 100);
}
}
break;
}
case 'claude-interactive-prompt':
// Interactive prompts are parsed/rendered as text in the UI.
// Normalize to string to keep ChatMessage.content shape consistent.
{
const interactiveContent =
typeof latestMessage.data === 'string'
? latestMessage.data
: JSON.stringify(latestMessage.data ?? '', null, 2);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: interactiveContent,
timestamp: new Date(),
isInteractivePrompt: true,
},
]);
}
break;
case 'claude-permission-request':
if (provider !== 'claude' || !latestMessage.requestId) {
break;
}
{
const requestId = latestMessage.requestId;
setPendingPermissionRequests((previous) => {
if (previous.some((request) => request.requestId === requestId)) {
return previous;
}
return [
...previous,
{
requestId,
toolName: latestMessage.toolName || 'UnknownTool',
input: latestMessage.input,
context: latestMessage.context,
sessionId: latestMessage.sessionId || null,
receivedAt: new Date(),
},
];
});
}
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Waiting for permission',
tokens: 0,
can_interrupt: true,
});
break;
case 'claude-permission-cancelled':
if (!latestMessage.requestId) {
break;
}
setPendingPermissionRequests((previous) =>
previous.filter((request) => request.requestId !== latestMessage.requestId),
);
break;
case 'claude-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Error: ${latestMessage.error}`,
timestamp: new Date(),
},
]);
break;
case 'cursor-system':
try {
const cursorData = latestMessage.data;
if (
cursorData &&
cursorData.type === 'system' &&
cursorData.subtype === 'init' &&
cursorData.session_id
) {
if (!isSystemInitForView) {
return;
}
if (currentSessionId && cursorData.session_id !== currentSessionId) {
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
if (!currentSessionId) {
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
}
} catch (error) {
console.warn('Error handling cursor-system message:', error);
}
break;
case 'cursor-user':
break;
case 'cursor-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
toolInput: latestMessage.input,
},
]);
break;
case 'cursor-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
timestamp: new Date(),
},
]);
break;
case 'cursor-result': {
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
finalizeLifecycleForCurrentView(
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 lastIndex = updated.length - 1;
const last = updated[lastIndex];
const normalizedTextResult = textResult.trim();
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
normalizedTextResult
? textResult
: `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
} else if (normalizedTextResult) {
const lastAssistantText =
last && last.type === 'assistant' && !last.isToolUse
? String(last.content || '').trim()
: '';
// Cursor can emit the same final text through both streaming and result payloads.
// Skip adding a second assistant bubble when the final text is unchanged.
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
if (isDuplicateFinalText) {
return updated;
}
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');
if (window.refreshProjects) {
setTimeout(() => window.refreshProjects?.(), 500);
}
}
break;
}
case 'cursor-output':
try {
const raw = String(latestMessage.data ?? '');
const cleaned = raw
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
.trim();
if (cleaned) {
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, true);
}, 100);
}
}
} catch (error) {
console.warn('Error handling cursor-output message:', error);
}
break;
case 'claude-complete': {
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId;
finalizeLifecycleForCurrentView(
completedSessionId,
currentSessionId,
selectedSession?.id,
pendingSessionId,
);
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
setCurrentSessionId(pendingSessionId);
sessionStorage.removeItem('pendingSessionId');
console.log('New session complete, ID set to:', pendingSessionId);
}
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
}
case 'codex-response': {
const codexData = latestMessage.data;
if (!codexData) {
break;
}
if (codexData.type === 'item') {
switch (codexData.itemType) {
case 'agent_message':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
break;
case 'reasoning':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
isThinking: true,
},
]);
}
break;
case 'command_execution':
if (codexData.command) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'Bash',
toolInput: codexData.command,
toolResult: codexData.output || null,
exitCode: codexData.exitCode,
},
]);
}
break;
case 'file_change':
if (codexData.changes?.length > 0) {
const changesList = codexData.changes
.map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`)
.join('\n');
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'FileChanges',
toolInput: changesList,
toolResult: {
content: `Status: ${codexData.status}`,
isError: false,
},
},
]);
}
break;
case 'mcp_tool_call':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: `${codexData.server}:${codexData.tool}`,
toolInput: JSON.stringify(codexData.arguments, null, 2),
toolResult: codexData.result
? JSON.stringify(codexData.result, null, 2)
: codexData.error?.message || null,
},
]);
break;
case 'error':
if (codexData.message?.content) {
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: codexData.message.content,
timestamp: new Date(),
},
]);
}
break;
default:
console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
}
}
if (codexData.type === 'turn_complete') {
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
}
if (codexData.type === 'turn_failed') {
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: codexData.error?.message || 'Turn failed',
timestamp: new Date(),
},
]);
}
break;
}
case 'codex-complete': {
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
finalizeLifecycleForCurrentView(
codexCompletedSessionId,
codexActualSessionId,
currentSessionId,
selectedSession?.id,
codexPendingSessionId,
);
if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (codexActualSessionId) {
onNavigateToSession?.(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
}
if (selectedProject) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
}
case 'codex-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Codex',
timestamp: new Date(),
},
]);
break;
case 'gemini-response': {
const geminiData = latestMessage.data;
if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') {
const content = decodeHtmlEntities(geminiData.content);
if (content) {
streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content;
}
if (!geminiData.isPartial) {
// Immediate flush and finalization for the last chunk
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
finalizeStreamingMessage(setChatMessages);
} else if (!streamTimerRef.current && streamBufferRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
}, 100);
}
}
break;
}
case 'gemini-error':
finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Gemini',
timestamp: new Date(),
},
]);
break;
case 'gemini-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.toolName,
toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '',
toolId: latestMessage.toolId,
toolResult: null,
}
]);
break;
case 'gemini-tool-result':
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === latestMessage.toolId) {
return {
...message,
toolResult: {
content: latestMessage.output || `Status: ${latestMessage.status}`,
isError: latestMessage.status === 'error',
timestamp: new Date(),
},
};
}
return message;
}),
);
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) {
finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId');
}
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: 'Session interrupted by user.',
timestamp: new Date(),
},
]);
} else {
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: 'Stop request failed. The session is still running.',
timestamp: new Date(),
},
]);
}
break;
}
case 'session-status': {
const statusSessionId = latestMessage.sessionId;
if (!statusSessionId) {
break;
}
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (latestMessage.isProcessing) {
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(true);
setCanAbortSession(true);
}
break;
}
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
clearLoadingIndicators();
}
break;
}
case 'claude-status': {
const statusData = latestMessage.data;
if (!statusData) {
break;
}
const statusInfo: { text: string; tokens: number; can_interrupt: boolean } = {
text: 'Working...',
tokens: 0,
can_interrupt: true,
};
if (statusData.message) {
statusInfo.text = statusData.message;
} else if (statusData.status) {
statusInfo.text = statusData.status;
} else if (typeof statusData === 'string') {
statusInfo.text = statusData;
}
if (statusData.tokens) {
statusInfo.tokens = statusData.tokens;
} else if (statusData.token_count) {
statusInfo.tokens = statusData.token_count;
}
if (statusData.can_interrupt !== undefined) {
statusInfo.can_interrupt = statusData.can_interrupt;
}
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
break;
}
case 'pending-permissions-response': {
// Server returned pending permissions for this session
const permSessionId = latestMessage.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) {
break;
}
const serverRequests = latestMessage.data || [];
setPendingPermissionRequests(serverRequests);
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;
}
}, [
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
]);
}