mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 09:27:22 +00:00
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
1182 lines
40 KiB
TypeScript
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,
|
|
]);
|
|
}
|