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 5fa545c..6ef4e91 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -97,6 +97,10 @@ function unescapeWithMathProtection(text) { return processedText; } +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // Small wrapper to keep markdown behavior consistent in one place const Markdown = ({ children, className }) => { const content = normalizeInlineCodeFences(String(children ?? '')); @@ -1894,6 +1898,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const messagesEndRef = useRef(null); const textareaRef = useRef(null); const inputContainerRef = useRef(null); + const inputHighlightRef = useRef(null); const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls const isLoadingMoreRef = useRef(false); @@ -1902,10 +1907,14 @@ 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); const [fileList, setFileList] = useState([]); + const [fileMentions, setFileMentions] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]); const [selectedFileIndex, setSelectedFileIndex] = useState(-1); const [cursorPosition, setCursorPosition] = useState(0); @@ -1939,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) { @@ -3013,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); @@ -3082,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 @@ -3103,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 @@ -3142,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) { @@ -3206,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) { @@ -3225,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); @@ -3253,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) { @@ -3322,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, @@ -3345,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 @@ -3366,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 } @@ -3534,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 }); @@ -4002,6 +4108,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess return result; }; + // Handle @ symbol detection and file filtering useEffect(() => { const textBeforeCursor = input.slice(0, cursorPosition); @@ -4032,6 +4139,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, [input, cursorPosition, fileList]); + const activeFileMentions = useMemo(() => { + if (!input || fileMentions.length === 0) return []; + return fileMentions.filter(path => input.includes(path)); + }, [fileMentions, input]); + + const sortedFileMentions = useMemo(() => { + if (activeFileMentions.length === 0) return []; + const unique = Array.from(new Set(activeFileMentions)); + return unique.sort((a, b) => b.length - a.length); + }, [activeFileMentions]); + + const fileMentionRegex = useMemo(() => { + if (sortedFileMentions.length === 0) return null; + const pattern = sortedFileMentions.map(escapeRegExp).join('|'); + return new RegExp(`(${pattern})`, 'g'); + }, [sortedFileMentions]); + + const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]); + + const renderInputWithMentions = useCallback((text) => { + if (!text) return ''; + if (!fileMentionRegex) return text; + const parts = text.split(fileMentionRegex); + return parts.map((part, index) => ( + fileMentionSet.has(part) ? ( + + {part} + + ) : ( + {part} + ) + )); + }, [fileMentionRegex, fileMentionSet]); + // Debounced input handling useEffect(() => { const timer = setTimeout(() => { @@ -4332,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); } @@ -4614,8 +4763,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const spaceIndex = textAfterAtQuery.indexOf(' '); const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : ''; - const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery; - const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1; + const newInput = textBeforeAt + file.path + ' ' + textAfterQuery; + const newCursorPos = textBeforeAt.length + file.path.length + 1; // Immediately ensure focus is maintained if (textareaRef.current && !textareaRef.current.matches(':focus')) { @@ -4625,6 +4774,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess // Update input and cursor position setInput(newInput); setCursorPosition(newCursorPos); + setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path])); // Hide dropdown setShowFileDropdown(false); @@ -4718,6 +4868,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }; + const syncInputOverlayScroll = useCallback((target) => { + if (!inputHighlightRef.current || !target) return; + inputHighlightRef.current.scrollTop = target.scrollTop; + inputHighlightRef.current.scrollLeft = target.scrollLeft; + }, []); + const handleTextareaClick = (e) => { setCursorPosition(e.target.selectionStart); }; @@ -5429,6 +5585,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
+ +