From 53af032d8833784353a2618b8f2ff4f03dafbedf Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Tue, 10 Mar 2026 23:22:24 +0300 Subject: [PATCH] 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 payload. - Added sanitizeCursorUserMessageText() to strip internal wrappers: , , , , . - 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 --- server/cursor-cli.js | 257 +++++++++--------- src/components/app/AppContent.tsx | 10 +- .../chat/hooks/useChatRealtimeHandlers.ts | 18 +- .../chat/utils/messageTransforms.ts | 46 ++++ src/hooks/useProjectsState.ts | 20 +- 5 files changed, 217 insertions(+), 134 deletions(-) diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 1f1da9c..f5fe7db 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -26,7 +26,6 @@ async function spawnCursor(command, options = {}, ws) { const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event - let messageBuffer = ''; // Buffer for accumulating assistant messages let hasRetriedWithTrust = false; let settled = false; @@ -81,6 +80,7 @@ async function spawnCursor(command, options = {}, ws) { const runCursorProcess = (args, runReason = 'initial') => { const isTrustRetry = runReason === 'trust-retry'; let runSawWorkspaceTrustPrompt = false; + let stdoutLineBuffer = ''; if (isTrustRetry) { console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); @@ -110,136 +110,137 @@ async function spawnCursor(command, options = {}, ws) { return true; }; + const processCursorOutputLine = (line) => { + if (!line || !line.trim()) { + return; + } + + try { + const response = JSON.parse(line); + console.log('Parsed JSON response:', response); + + // Handle different message types + switch (response.type) { + case 'system': + if (response.subtype === 'init') { + // Capture session ID + if (response.session_id && !capturedSessionId) { + capturedSessionId = response.session_id; + console.log('Captured session ID:', capturedSessionId); + + // Update process key with captured session ID + if (processKey !== capturedSessionId) { + activeCursorProcesses.delete(processKey); + activeCursorProcesses.set(capturedSessionId, cursorProcess); + } + + // Set session ID on writer (for API endpoint compatibility) + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + // Send session-created event only once for new sessions + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send({ + type: 'session-created', + sessionId: capturedSessionId, + model: response.model, + cwd: response.cwd + }); + } + } + + // Send system info to frontend + ws.send({ + type: 'cursor-system', + data: response, + sessionId: capturedSessionId || sessionId || null + }); + } + break; + + case 'user': + // Forward user message + ws.send({ + type: 'cursor-user', + data: response, + sessionId: capturedSessionId || sessionId || null + }); + break; + + case 'assistant': + // Accumulate assistant message chunks + if (response.message && response.message.content && response.message.content.length > 0) { + const textContent = response.message.content[0].text; + + // Send as Claude-compatible format for frontend + ws.send({ + type: 'claude-response', + data: { + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: textContent + } + }, + sessionId: capturedSessionId || sessionId || null + }); + } + break; + + case 'result': + // Session complete + console.log('Cursor session result:', response); + + // Do not emit an extra content_block_stop here. + // The UI already finalizes the streaming message in cursor-result handling, + // and emitting both can produce duplicate assistant messages. + ws.send({ + type: 'cursor-result', + sessionId: capturedSessionId || sessionId, + data: response, + success: response.subtype === 'success' + }); + break; + + default: + // Forward any other message types + ws.send({ + type: 'cursor-response', + data: response, + sessionId: capturedSessionId || sessionId || null + }); + } + } catch (parseError) { + console.log('Non-JSON response:', line); + + if (shouldSuppressForTrustRetry(line)) { + return; + } + + // If not JSON, send as raw text + ws.send({ + type: 'cursor-output', + data: line, + sessionId: capturedSessionId || sessionId || null + }); + } + }; + // Handle stdout (streaming JSON responses) cursorProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); console.log('Cursor CLI stdout:', rawOutput); - const lines = rawOutput.split('\n').filter((line) => line.trim()); + // Stream chunks can split JSON objects across packets; keep trailing partial line. + stdoutLineBuffer += rawOutput; + const completeLines = stdoutLineBuffer.split(/\r?\n/); + stdoutLineBuffer = completeLines.pop() || ''; - for (const line of lines) { - try { - const response = JSON.parse(line); - console.log('Parsed JSON response:', response); - - // Handle different message types - switch (response.type) { - case 'system': - if (response.subtype === 'init') { - // Capture session ID - if (response.session_id && !capturedSessionId) { - capturedSessionId = response.session_id; - console.log('Captured session ID:', capturedSessionId); - - // Update process key with captured session ID - if (processKey !== capturedSessionId) { - activeCursorProcesses.delete(processKey); - activeCursorProcesses.set(capturedSessionId, cursorProcess); - } - - // Set session ID on writer (for API endpoint compatibility) - if (ws.setSessionId && typeof ws.setSessionId === 'function') { - ws.setSessionId(capturedSessionId); - } - - // Send session-created event only once for new sessions - if (!sessionId && !sessionCreatedSent) { - sessionCreatedSent = true; - ws.send({ - type: 'session-created', - sessionId: capturedSessionId, - model: response.model, - cwd: response.cwd - }); - } - } - - // Send system info to frontend - ws.send({ - type: 'cursor-system', - data: response, - sessionId: capturedSessionId || sessionId || null - }); - } - break; - - case 'user': - // Forward user message - ws.send({ - type: 'cursor-user', - data: response, - sessionId: capturedSessionId || sessionId || null - }); - break; - - case 'assistant': - // Accumulate assistant message chunks - if (response.message && response.message.content && response.message.content.length > 0) { - const textContent = response.message.content[0].text; - messageBuffer += textContent; - - // Send as Claude-compatible format for frontend - ws.send({ - type: 'claude-response', - data: { - type: 'content_block_delta', - delta: { - type: 'text_delta', - text: textContent - } - }, - sessionId: capturedSessionId || sessionId || null - }); - } - break; - - case 'result': - // Session complete - console.log('Cursor session result:', response); - - // Send final message if we have buffered content - if (messageBuffer) { - ws.send({ - type: 'claude-response', - data: { - type: 'content_block_stop' - }, - sessionId: capturedSessionId || sessionId || null - }); - } - - // Send completion event - ws.send({ - type: 'cursor-result', - sessionId: capturedSessionId || sessionId, - data: response, - success: response.subtype === 'success' - }); - break; - - default: - // Forward any other message types - ws.send({ - type: 'cursor-response', - data: response, - sessionId: capturedSessionId || sessionId || null - }); - } - } catch (parseError) { - console.log('Non-JSON response:', line); - - if (shouldSuppressForTrustRetry(line)) { - continue; - } - - // If not JSON, send as raw text - ws.send({ - type: 'cursor-output', - data: line, - sessionId: capturedSessionId || sessionId || null - }); - } - } + completeLines.forEach((line) => { + processCursorOutputLine(line.trim()); + }); }); // Handle stderr @@ -265,6 +266,12 @@ async function spawnCursor(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); + // Flush any final unterminated stdout line before completion handling. + if (stdoutLineBuffer.trim()) { + processCursorOutputLine(stdoutLineBuffer.trim()); + stdoutLineBuffer = ''; + } + if ( runSawWorkspaceTrustPrompt && code !== 0 && diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index f2806c6..5649c0c 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -40,7 +40,7 @@ export default function AppContent() { setIsInputFocused, setShowSettings, openSettings, - fetchProjects, + refreshProjectsSilently, sidebarSharedProps, } = useProjectsState({ sessionId, @@ -51,14 +51,16 @@ export default function AppContent() { }); useEffect(() => { - window.refreshProjects = fetchProjects; + // Expose a non-blocking refresh for chat/session flows. + // Full loading refreshes are still available through direct fetchProjects calls. + window.refreshProjects = refreshProjectsSilently; return () => { - if (window.refreshProjects === fetchProjects) { + if (window.refreshProjects === refreshProjectsSilently) { delete window.refreshProjects; } }; - }, [fetchProjects]); + }, [refreshProjectsSilently]); useEffect(() => { window.openSettings = openSettings; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 73c305d..d543359 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -692,14 +692,28 @@ export function useChatRealtimeHandlers({ 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 = - textResult && textResult.trim() + 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 (textResult && textResult.trim()) { + } 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, diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index b4a5250..cf38e44 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => { } }; +const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [ + /[\s\S]*?<\/user_info>/gi, + /[\s\S]*?<\/agent_skills>/gi, + /[\s\S]*?<\/available_skills>/gi, + /[\s\S]*?<\/environment_context>/gi, + /[\s\S]*?<\/environment_info>/gi, +]; + +const extractCursorUserQuery = (rawText: string): string => { + const userQueryMatches = [...rawText.matchAll(/([\s\S]*?)<\/user_query>/gi)]; + if (userQueryMatches.length === 0) { + return ''; + } + + return userQueryMatches + .map((match) => (match[1] || '').trim()) + .filter(Boolean) + .join('\n') + .trim(); +}; + +const sanitizeCursorUserMessageText = (rawText: string): string => { + const decodedText = decodeHtmlEntities(rawText || '').trim(); + if (!decodedText) { + return ''; + } + + // Cursor stores user-visible text inside and prepends hidden context blocks + // (, , etc). We only render the actual query in chat history. + const extractedUserQuery = extractCursorUserQuery(decodedText); + if (extractedUserQuery) { + return extractedUserQuery; + } + + let sanitizedText = decodedText; + CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => { + sanitizedText = sanitizedText.replace(pattern, ''); + }); + + return sanitizedText.trim(); +}; + const toAbsolutePath = (projectPath: string, filePath?: string) => { if (!filePath) { return filePath; @@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s console.log('Error parsing blob content:', error); } + if (role === 'user') { + text = sanitizeCursorUserMessageText(text); + } + if (text && text.trim()) { const message: ChatMessage = { type: role, diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 8461253..28cf682 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -18,6 +18,10 @@ type UseProjectsStateArgs = { activeSessions: Set; }; +type FetchProjectsOptions = { + showLoadingState?: boolean; +}; + const serialize = (value: unknown) => JSON.stringify(value ?? null); const projectsHaveChanges = ( @@ -152,9 +156,11 @@ export function useProjectsState({ const loadingProgressTimeoutRef = useRef | null>(null); - const fetchProjects = useCallback(async () => { + const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => { try { - setIsLoadingProjects(true); + if (showLoadingState) { + setIsLoadingProjects(true); + } const response = await api.projects(); const projectData = (await response.json()) as Project[]; @@ -170,10 +176,17 @@ export function useProjectsState({ } catch (error) { console.error('Error fetching projects:', error); } finally { - setIsLoadingProjects(false); + if (showLoadingState) { + setIsLoadingProjects(false); + } } }, []); + const refreshProjectsSilently = useCallback(async () => { + // Keep chat view stable while still syncing sidebar/session metadata in background. + await fetchProjects({ showLoadingState: false }); + }, [fetchProjects]); + const openSettings = useCallback((tab = 'tools') => { setSettingsInitialTab(tab); setShowSettings(true); @@ -547,6 +560,7 @@ export function useProjectsState({ setShowSettings, openSettings, fetchProjects, + refreshProjectsSilently, sidebarSharedProps, handleProjectSelect, handleSessionSelect,