diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 8366691..0f7c39a 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -603,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) { const transformedMessage = transformMessage(message); ws.send({ type: 'claude-response', - data: transformedMessage + data: transformedMessage, + sessionId: capturedSessionId || sessionId || null }); // Extract and send token budget updates from result messages @@ -613,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) { console.log('Token budget from modelUsage:', tokenBudget); ws.send({ type: 'token-budget', - data: tokenBudget + data: tokenBudget, + sessionId: capturedSessionId || sessionId || null }); } } @@ -651,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) { // Send error to WebSocket ws.send({ type: 'claude-error', - error: error.message + error: error.message, + sessionId: capturedSessionId || sessionId || null }); throw error; diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 1e5c2e9..ffd20c3 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) { // Send system info to frontend ws.send({ type: 'cursor-system', - data: response + data: response, + sessionId: capturedSessionId || sessionId || null }); } break; @@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) { // Forward user message ws.send({ type: 'cursor-user', - data: response + data: response, + sessionId: capturedSessionId || sessionId || null }); break; @@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) { type: 'text_delta', text: textContent } - } + }, + sessionId: capturedSessionId || sessionId || null }); } break; @@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) { type: 'claude-response', data: { type: 'content_block_stop' - } + }, + sessionId: capturedSessionId || sessionId || null }); } @@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) { // Forward any other message types ws.send({ type: 'cursor-response', - data: response + data: response, + sessionId: capturedSessionId || sessionId || null }); } } catch (parseError) { @@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) { // If not JSON, send as raw text ws.send({ type: 'cursor-output', - data: line + data: line, + sessionId: capturedSessionId || sessionId || null }); } } @@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) { console.error('Cursor CLI stderr:', data.toString()); ws.send({ type: 'cursor-error', - error: data.toString() + error: data.toString(), + sessionId: capturedSessionId || sessionId || null }); }); @@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'cursor-error', - error: error.message + error: error.message, + sessionId: capturedSessionId || sessionId || null }); reject(error); @@ -264,4 +272,4 @@ export { abortCursorSession, isCursorSessionActive, getActiveCursorSessions -}; \ No newline at end of file +}; diff --git a/server/openai-codex.js b/server/openai-codex.js index d83d39b..1967de4 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) { data: { used: totalTokens, total: 200000 // Default context window for Codex models - } + }, + sessionId: currentSessionId }); } } diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index aeb8705..6ef4e91 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1907,6 +1907,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Streaming throttle buffers const streamBufferRef = useRef(''); const streamTimerRef = useRef(null); + // Track the session that this view expects when starting a brandโ€‘new chat + // (prevents background sessions from streaming into a different view). + const pendingViewSessionRef = useRef(null); const commandQueryTimerRef = useRef(null); const [debouncedInput, setDebouncedInput] = useState(''); const [showFileDropdown, setShowFileDropdown] = useState(false); @@ -1945,6 +1948,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Track provider transitions so we only clear approvals when provider truly changes. // This does not sync with the backend; it just prevents UI prompts from disappearing. const lastProviderRef = useRef(provider); + + const resetStreamingState = useCallback(() => { + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; + } + streamBufferRef.current = ''; + }, []); // Load permission mode for the current session useEffect(() => { if (selectedSession?.id) { @@ -3019,6 +3030,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { + if (!isSystemSessionChange) { + // Clear any streaming leftovers from the previous session + resetStreamingState(); + pendingViewSessionRef.current = null; + setChatMessages([]); + setSessionMessages([]); + setClaudeStatus(null); + setCanAbortSession(false); + } // Reset pagination state when switching sessions setMessagesOffset(0); setHasMoreMessages(false); @@ -3088,17 +3108,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } } } else { - // Only clear messages if this is NOT a system-initiated session change AND we're not loading - // During system session changes or while loading, preserve the chat messages - if (!isSystemSessionChange && !isLoading) { + // New session view (no selected session) - always reset UI state + if (!isSystemSessionChange) { + resetStreamingState(); + pendingViewSessionRef.current = null; setChatMessages([]); setSessionMessages([]); + setClaudeStatus(null); + setCanAbortSession(false); + setIsLoading(false); } setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); + setTokenBudget(null); } // Mark loading as complete after messages are set @@ -3109,7 +3134,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }; loadMessages(); - }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); + }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]); // External Message Update Handler: Reload messages when external CLI modifies current session // This triggers when App.jsx detects a JSONL file change for the currently-viewed session @@ -3148,6 +3173,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); + // When the user navigates to a specific session, clear any pending "new session" marker. + useEffect(() => { + if (selectedSession?.id) { + pendingViewSessionRef.current = null; + } + }, [selectedSession?.id]); + // Update chatMessages when convertedMessages changes useEffect(() => { if (sessionMessages.length > 0) { @@ -3212,17 +3244,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Handle WebSocket messages if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; + const messageData = latestMessage.data?.message || latestMessage.data; // Filter messages by session ID to prevent cross-session interference // Skip filtering for global messages that apply to all sessions - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete']; + const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); + const lifecycleMessageTypes = new Set([ + 'claude-complete', + 'codex-complete', + 'cursor-result', + 'session-aborted', + 'claude-error', + 'cursor-error', + 'codex-error' + ]); - // For new sessions (currentSessionId is null), allow messages through - if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) { - // Message is for a different session, ignore it - console.log('โญ๏ธ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId); - return; + const isClaudeSystemInit = latestMessage.type === 'claude-response' && + messageData && + messageData.type === 'system' && + messageData.subtype === 'init'; + const isCursorSystemInit = latestMessage.type === 'cursor-system' && + latestMessage.data && + latestMessage.data.type === 'system' && + latestMessage.data.subtype === 'init'; + + const systemInitSessionId = isClaudeSystemInit + ? messageData?.session_id + : isCursorSystemInit + ? latestMessage.data?.session_id + : null; + + const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; + const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); + const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView; + const isUnscopedError = !latestMessage.sessionId && + pendingViewSessionRef.current && + !pendingViewSessionRef.current.sessionId && + (latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error'); + + const handleBackgroundLifecycle = (sessionId) => { + if (!sessionId) return; + if (onSessionInactive) { + onSessionInactive(sessionId); + } + if (onSessionNotProcessing) { + onSessionNotProcessing(sessionId); + } + }; + + if (!shouldBypassSessionFilter) { + if (!activeViewSessionId) { + // No session in view; ignore session-scoped traffic. + if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { + handleBackgroundLifecycle(latestMessage.sessionId); + } + if (!isUnscopedError) { + return; + } + } + if (!latestMessage.sessionId && !isUnscopedError) { + // Drop unscoped messages to prevent cross-session bleed. + return; + } + if (latestMessage.sessionId !== activeViewSessionId) { + if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { + handleBackgroundLifecycle(latestMessage.sessionId); + } + // Message is for a different session, ignore it + console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId); + return; + } } switch (latestMessage.type) { @@ -3231,6 +3323,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Store it temporarily until conversation completes (prevents premature session association) if (latestMessage.sessionId && !currentSessionId) { sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); + if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { + pendingViewSessionRef.current.sessionId = latestMessage.sessionId; + } // Mark as system change to prevent clearing messages when session ID updates setIsSystemSessionChange(true); @@ -3259,7 +3354,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess break; case 'claude-response': - const messageData = latestMessage.data.message || latestMessage.data; // Handle Cursor streaming format (content_block_delta / content_block_stop) if (messageData && typeof messageData === 'object' && messageData.type) { @@ -3328,7 +3422,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess latestMessage.data.subtype === 'init' && latestMessage.data.session_id && currentSessionId && - latestMessage.data.session_id !== currentSessionId) { + latestMessage.data.session_id !== currentSessionId && + isSystemInitForView) { console.log('๐Ÿ”„ Claude CLI session duplication detected:', { originalSession: currentSessionId, @@ -3351,7 +3446,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (latestMessage.data.type === 'system' && latestMessage.data.subtype === 'init' && latestMessage.data.session_id && - !currentSessionId) { + !currentSessionId && + isSystemInitForView) { console.log('๐Ÿ”„ New session init detected:', { newSession: latestMessage.data.session_id @@ -3372,7 +3468,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess latestMessage.data.subtype === 'init' && latestMessage.data.session_id && currentSessionId && - latestMessage.data.session_id === currentSessionId) { + latestMessage.data.session_id === currentSessionId && + isSystemInitForView) { console.log('๐Ÿ”„ System init message for current session, ignoring'); return; // Don't process the message further } @@ -3540,6 +3637,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess try { const cdata = latestMessage.data; if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) { + if (!isSystemInitForView) { + return; + } // If we already have a session and this differs, switch (duplication/redirect) if (currentSessionId && cdata.session_id !== currentSessionId) { console.log('๐Ÿ”„ Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id }); @@ -4376,6 +4476,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Session Protection: Mark session as active to prevent automatic project updates during conversation // Use existing session if available; otherwise a temporary placeholder until backend provides real ID const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; + if (!effectiveSessionId && !selectedSession?.id) { + // We are starting a brand-new session in this view. Track it so we only + // accept streaming updates for this run. + pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; + } if (onSessionActive) { onSessionActive(sessionToActivate); }