/* * App.jsx - Main Application Component with Session Protection System * * SESSION PROTECTION SYSTEM OVERVIEW: * =================================== * * Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages * during active conversations, creating a poor user experience. * * Solution: Track "active sessions" and pause project updates during conversations. * * How it works: * 1. When user sends message β session marked as "active" * 2. Project updates are skipped while session is active * 3. When conversation completes/aborts β session marked as "inactive" * 4. Project updates resume normally * * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). */ import React, { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; import Sidebar from './components/Sidebar'; import MainContent from './components/MainContent'; import MobileNav from './components/MobileNav'; import Settings from './components/Settings'; import QuickSettingsPanel from './components/QuickSettingsPanel'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext'; import ProtectedRoute from './components/ProtectedRoute'; import { useVersionCheck } from './hooks/useVersionCheck'; import useLocalStorage from './hooks/useLocalStorage'; import { api, authenticatedFetch } from './utils/api'; // Main App component with routing function AppContent() { const navigate = useNavigate(); const { sessionId } = useParams(); const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui'); const [showVersionModal, setShowVersionModal] = useState(false); const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files' const [isMobile, setIsMobile] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [isInputFocused, setIsInputFocused] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showQuickSettings, setShowQuickSettings] = useState(false); const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false); const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false); const [showThinking, setShowThinking] = useLocalStorage('showThinking', true); const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true); const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false); // Session Protection System: Track sessions with active conversations to prevent // automatic project updates from interrupting ongoing chats. When a user sends // a message, the session is marked as "active" and project updates are paused // until the conversation completes or is aborted. const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations // Processing Sessions: Track which sessions are currently thinking/processing // This allows us to restore the "Thinking..." banner when switching back to a processing session const [processingSessions, setProcessingSessions] = useState(new Set()); // External Message Update Trigger: Incremented when external CLI modifies current session's JSONL // Triggers ChatInterface to reload messages without switching sessions const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const { ws, sendMessage, messages } = useWebSocketContext(); // Detect if running as PWA const [isPWA, setIsPWA] = useState(false); useEffect(() => { // Check if running in standalone mode (PWA) const checkPWA = () => { const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone || document.referrer.includes('android-app://'); setIsPWA(isStandalone); // Add class to html and body for CSS targeting if (isStandalone) { document.documentElement.classList.add('pwa-mode'); document.body.classList.add('pwa-mode'); } else { document.documentElement.classList.remove('pwa-mode'); document.body.classList.remove('pwa-mode'); } }; checkPWA(); // Listen for changes window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA); return () => { window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA); }; }, []); useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 768); }; checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); useEffect(() => { // Fetch projects on component mount fetchProjects(); }, []); // Helper function to determine if an update is purely additive (new sessions/projects) // vs modifying existing selected items that would interfere with active conversations const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => { if (!selectedProject || !selectedSession) { // No active session to protect, allow all updates return true; } // Find the selected project in both current and updated data const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name); const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name); if (!currentSelectedProject || !updatedSelectedProject) { // Project structure changed significantly, not purely additive return false; } // Find the selected session in both current and updated project data const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id); const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); if (!currentSelectedSession || !updatedSelectedSession) { // Selected session was deleted or significantly changed, not purely additive return false; } // Check if the selected session's content has changed (modification vs addition) // Compare key fields that would affect the loaded chat interface const sessionUnchanged = currentSelectedSession.id === updatedSelectedSession.id && currentSelectedSession.title === updatedSelectedSession.title && currentSelectedSession.created_at === updatedSelectedSession.created_at && currentSelectedSession.updated_at === updatedSelectedSession.updated_at; // This is considered additive if the selected session is unchanged // (new sessions may have been added elsewhere, but active session is protected) return sessionUnchanged; }; // Handle WebSocket messages for real-time project updates useEffect(() => { if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; if (latestMessage.type === 'projects_updated') { // External Session Update Detection: Check if the changed file is the current session's JSONL // If so, and the session is not active, trigger a message reload in ChatInterface if (latestMessage.changedFile && selectedSession && selectedProject) { // Extract session ID from changedFile (format: "project-name/session-id.jsonl") const changedFileParts = latestMessage.changedFile.split('/'); if (changedFileParts.length >= 2) { const filename = changedFileParts[changedFileParts.length - 1]; const changedSessionId = filename.replace('.jsonl', ''); // Check if this is the currently-selected session if (changedSessionId === selectedSession.id) { const isSessionActive = activeSessions.has(selectedSession.id); if (!isSessionActive) { // Session is not active - safe to reload messages console.log('π External CLI update detected for current session:', changedSessionId); setExternalMessageUpdate(prev => prev + 1); } else { // Session is active - skip reload to avoid interrupting user console.log('βΈοΈ External update paused - session is active:', changedSessionId); } } } } // Session Protection Logic: Allow additions but prevent changes during active conversations // This allows new sessions/projects to appear in sidebar while protecting active chat messages // We check for two types of active sessions: // 1. Existing sessions: selectedSession.id exists in activeSessions // 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received) const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) || (activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-'))); if (hasActiveSession) { // Allow updates but be selective: permit additions, prevent changes to existing items const updatedProjects = latestMessage.projects; const currentProjects = projects; // Check if this is purely additive (new sessions/projects) vs modification of existing ones const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession); if (!isAdditiveUpdate) { // Skip updates that would modify existing selected session/project return; } // Continue with additive updates below } // Update projects state with the new data from WebSocket const updatedProjects = latestMessage.projects; setProjects(updatedProjects); // Update selected project if it exists in the updated projects if (selectedProject) { const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name); if (updatedSelectedProject) { // Only update selected project if it actually changed - prevents flickering if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) { setSelectedProject(updatedSelectedProject); } // Update selected session only if it was deleted - avoid unnecessary reloads if (selectedSession) { const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); if (!updatedSelectedSession) { // Session was deleted setSelectedSession(null); } // Don't update if session still exists with same ID - prevents reload } } } } } }, [messages, selectedProject, selectedSession, activeSessions]); const fetchProjects = async () => { try { setIsLoadingProjects(true); const response = await api.projects(); const data = await response.json(); // Always fetch Cursor sessions for each project so we can combine views for (let project of data) { try { const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`; const cursorResponse = await authenticatedFetch(url); if (cursorResponse.ok) { const cursorData = await cursorResponse.json(); if (cursorData.success && cursorData.sessions) { project.cursorSessions = cursorData.sessions; } else { project.cursorSessions = []; } } else { project.cursorSessions = []; } } catch (error) { console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); project.cursorSessions = []; } } // Optimize to preserve object references when data hasn't changed setProjects(prevProjects => { // If no previous projects, just set the new data if (prevProjects.length === 0) { return data; } // Check if the projects data has actually changed const hasChanges = data.some((newProject, index) => { const prevProject = prevProjects[index]; if (!prevProject) return true; // Compare key properties that would affect UI return ( newProject.name !== prevProject.name || newProject.displayName !== prevProject.displayName || newProject.fullPath !== prevProject.fullPath || JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) || JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions) ); }) || data.length !== prevProjects.length; // Only update if there are actual changes return hasChanges ? data : prevProjects; }); // Don't auto-select any project - user should choose manually } catch (error) { console.error('Error fetching projects:', error); } finally { setIsLoadingProjects(false); } }; // Expose fetchProjects globally for component access window.refreshProjects = fetchProjects; // Handle URL-based session loading useEffect(() => { if (sessionId && projects.length > 0) { // Only switch tabs on initial load, not on every project update const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; // Find the session across all projects for (const project of projects) { let session = project.sessions?.find(s => s.id === sessionId); if (session) { setSelectedProject(project); setSelectedSession({ ...session, __provider: 'claude' }); // Only switch to chat tab if we're loading a different session if (shouldSwitchTab) { setActiveTab('chat'); } return; } // Also check Cursor sessions const cSession = project.cursorSessions?.find(s => s.id === sessionId); if (cSession) { setSelectedProject(project); setSelectedSession({ ...cSession, __provider: 'cursor' }); if (shouldSwitchTab) { setActiveTab('chat'); } return; } } // If session not found, it might be a newly created session // Just navigate to it and it will be found when the sidebar refreshes // Don't redirect to home, let the session load naturally } }, [sessionId, projects, navigate]); const handleProjectSelect = (project) => { setSelectedProject(project); setSelectedSession(null); navigate('/'); if (isMobile) { setSidebarOpen(false); } }; const handleSessionSelect = (session) => { setSelectedSession(session); // Only switch to chat tab when user explicitly selects a session // This prevents tab switching during automatic updates if (activeTab !== 'git' && activeTab !== 'preview') { setActiveTab('chat'); } // For Cursor sessions, we need to set the session ID differently // since they're persistent and not created by Claude const provider = localStorage.getItem('selected-provider') || 'claude'; if (provider === 'cursor') { // Cursor sessions have persistent IDs sessionStorage.setItem('cursorSessionId', session.id); } // Only close sidebar on mobile if switching to a different project if (isMobile) { const sessionProjectName = session.__projectName; const currentProjectName = selectedProject?.name; // Close sidebar if clicking a session from a different project // Keep it open if clicking a session from the same project if (sessionProjectName !== currentProjectName) { setSidebarOpen(false); } } navigate(`/session/${session.id}`); }; const handleNewSession = (project) => { setSelectedProject(project); setSelectedSession(null); setActiveTab('chat'); navigate('/'); if (isMobile) { setSidebarOpen(false); } }; const handleSessionDelete = (sessionId) => { // If the deleted session was currently selected, clear it if (selectedSession?.id === sessionId) { setSelectedSession(null); navigate('/'); } // Update projects state locally instead of full refresh setProjects(prevProjects => prevProjects.map(project => ({ ...project, sessions: project.sessions?.filter(session => session.id !== sessionId) || [], sessionMeta: { ...project.sessionMeta, total: Math.max(0, (project.sessionMeta?.total || 0) - 1) } })) ); }; const handleSidebarRefresh = async () => { // Refresh only the sessions for all projects, don't change selected state try { const response = await api.projects(); const freshProjects = await response.json(); // Optimize to preserve object references and minimize re-renders setProjects(prevProjects => { // Check if projects data has actually changed const hasChanges = freshProjects.some((newProject, index) => { const prevProject = prevProjects[index]; if (!prevProject) return true; return ( newProject.name !== prevProject.name || newProject.displayName !== prevProject.displayName || newProject.fullPath !== prevProject.fullPath || JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) ); }) || freshProjects.length !== prevProjects.length; return hasChanges ? freshProjects : prevProjects; }); // If we have a selected project, make sure it's still selected after refresh if (selectedProject) { const refreshedProject = freshProjects.find(p => p.name === selectedProject.name); if (refreshedProject) { // Only update selected project if it actually changed if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) { setSelectedProject(refreshedProject); } // If we have a selected session, try to find it in the refreshed project if (selectedSession) { const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id); if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) { setSelectedSession(refreshedSession); } } } } } catch (error) { console.error('Error refreshing sidebar:', error); } }; const handleProjectDelete = (projectName) => { // If the deleted project was currently selected, clear it if (selectedProject?.name === projectName) { setSelectedProject(null); setSelectedSession(null); navigate('/'); } // Update projects state locally instead of full refresh setProjects(prevProjects => prevProjects.filter(project => project.name !== projectName) ); }; // Session Protection Functions: Manage the lifecycle of active sessions // markSessionAsActive: Called when user sends a message to mark session as protected // This includes both real session IDs and temporary "new-session-*" identifiers const markSessionAsActive = (sessionId) => { if (sessionId) { setActiveSessions(prev => new Set([...prev, sessionId])); } }; // markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates const markSessionAsInactive = (sessionId) => { if (sessionId) { setActiveSessions(prev => { const newSet = new Set(prev); newSet.delete(sessionId); return newSet; }); } }; // Processing Session Functions: Track which sessions are currently thinking/processing // markSessionAsProcessing: Called when Claude starts thinking/processing const markSessionAsProcessing = (sessionId) => { if (sessionId) { setProcessingSessions(prev => new Set([...prev, sessionId])); } }; // markSessionAsNotProcessing: Called when Claude finishes thinking/processing const markSessionAsNotProcessing = (sessionId) => { if (sessionId) { setProcessingSessions(prev => { const newSet = new Set(prev); newSet.delete(sessionId); return newSet; }); } }; // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions // Removes temporary "new-session-*" identifiers and adds the real session ID // This maintains protection continuity during the transition from temporary to real session const replaceTemporarySession = (realSessionId) => { if (realSessionId) { setActiveSessions(prev => { const newSet = new Set(); // Keep all non-temporary sessions and add the real session ID for (const sessionId of prev) { if (!sessionId.startsWith('new-session-')) { newSet.add(sessionId); } } newSet.add(realSessionId); return newSet; }); } }; // Version Upgrade Modal Component const VersionUpgradeModal = () => { if (!showVersionModal) return null; return (