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
This commit is contained in:
Haileyesus
2026-03-10 23:22:24 +03:00
parent a780cb6523
commit 53af032d88
5 changed files with 217 additions and 134 deletions

View File

@@ -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 &&

View File

@@ -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;

View File

@@ -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,

View File

@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
}
};
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
/<user_info>[\s\S]*?<\/user_info>/gi,
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
/<available_skills>[\s\S]*?<\/available_skills>/gi,
/<environment_context>[\s\S]*?<\/environment_context>/gi,
/<environment_info>[\s\S]*?<\/environment_info>/gi,
];
const extractCursorUserQuery = (rawText: string): string => {
const userQueryMatches = [...rawText.matchAll(/<user_query>([\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 <user_query> and prepends hidden context blocks
// (<user_info>, <agent_skills>, 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,

View File

@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
activeSessions: Set<string>;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
const serialize = (value: unknown) => JSON.stringify(value ?? null);
const projectsHaveChanges = (
@@ -152,9 +156,11 @@ export function useProjectsState({
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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,