import React, { useState, useEffect, useRef } from 'react'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; import { Input } from './ui/input'; import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react'; import { cn } from '../lib/utils'; import ClaudeLogo from './ClaudeLogo'; import CursorLogo from './CursorLogo.jsx'; import TaskIndicator from './TaskIndicator'; import { api } from '../utils/api'; import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; // Move formatTimeAgo outside component to avoid recreation on every render const formatTimeAgo = (dateString, currentTime) => { const date = new Date(dateString); const now = currentTime; // Check if date is valid if (isNaN(date.getTime())) { return 'Unknown'; } const diffInMs = now - date; const diffInSeconds = Math.floor(diffInMs / 1000); const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); if (diffInSeconds < 60) return 'Just now'; if (diffInMinutes === 1) return '1 min ago'; if (diffInMinutes < 60) return `${diffInMinutes} mins ago`; if (diffInHours === 1) return '1 hour ago'; if (diffInHours < 24) return `${diffInHours} hours ago`; if (diffInDays === 1) return '1 day ago'; if (diffInDays < 7) return `${diffInDays} days ago`; return date.toLocaleDateString(); }; function Sidebar({ projects, selectedProject, selectedSession, onProjectSelect, onSessionSelect, onNewSession, onSessionDelete, onProjectDelete, isLoading, onRefresh, onShowSettings, updateAvailable, latestVersion, currentVersion, releaseInfo, onShowVersionModal, isPWA, isMobile, onToggleSidebar }) { const [expandedProjects, setExpandedProjects] = useState(new Set()); const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); const [editingName, setEditingName] = useState(''); const [newProjectPath, setNewProjectPath] = useState(''); const [creatingProject, setCreatingProject] = useState(false); const [loadingSessions, setLoadingSessions] = useState({}); const [additionalSessions, setAdditionalSessions] = useState({}); const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set()); const [currentTime, setCurrentTime] = useState(new Date()); const [projectSortOrder, setProjectSortOrder] = useState('name'); const [isRefreshing, setIsRefreshing] = useState(false); const [editingSession, setEditingSession] = useState(null); const [editingSessionName, setEditingSessionName] = useState(''); const [generatingSummary, setGeneratingSummary] = useState({}); const [searchFilter, setSearchFilter] = useState(''); const [showPathDropdown, setShowPathDropdown] = useState(false); const [pathList, setPathList] = useState([]); const [filteredPaths, setFilteredPaths] = useState([]); const [selectedPathIndex, setSelectedPathIndex] = useState(-1); // TaskMaster context const { setCurrentProject, mcpServerStatus } = useTaskMaster(); const { tasksEnabled } = useTasksSettings(); // Starred projects state - persisted in localStorage const [starredProjects, setStarredProjects] = useState(() => { try { const saved = localStorage.getItem('starredProjects'); return saved ? new Set(JSON.parse(saved)) : new Set(); } catch (error) { console.error('Error loading starred projects:', error); return new Set(); } }); // Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas) const handleTouchClick = (callback) => { return (e) => { // Only prevent default for buttons/clickable elements, not scrollable areas if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) { return; } e.preventDefault(); e.stopPropagation(); callback(); }; }; // Auto-update timestamps every minute useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); }, 60000); // Update every 60 seconds return () => clearInterval(timer); }, []); // Clear additional sessions when projects list changes (e.g., after refresh) useEffect(() => { setAdditionalSessions({}); setInitialSessionsLoaded(new Set()); }, [projects]); // Auto-expand project folder when a session is selected useEffect(() => { if (selectedSession && selectedProject) { setExpandedProjects(prev => new Set([...prev, selectedProject.name])); } }, [selectedSession, selectedProject]); // Mark sessions as loaded when projects come in useEffect(() => { if (projects.length > 0 && !isLoading) { const newLoaded = new Set(); projects.forEach(project => { if (project.sessions && project.sessions.length >= 0) { newLoaded.add(project.name); } }); setInitialSessionsLoaded(newLoaded); } }, [projects, isLoading]); // Load project sort order from settings useEffect(() => { const loadSortOrder = () => { try { const savedSettings = localStorage.getItem('claude-settings'); if (savedSettings) { const settings = JSON.parse(savedSettings); setProjectSortOrder(settings.projectSortOrder || 'name'); } } catch (error) { console.error('Error loading sort order:', error); } }; // Load initially loadSortOrder(); // Listen for storage changes const handleStorageChange = (e) => { if (e.key === 'claude-settings') { loadSortOrder(); } }; window.addEventListener('storage', handleStorageChange); // Also check periodically when component is focused (for same-tab changes) const checkInterval = setInterval(() => { if (document.hasFocus()) { loadSortOrder(); } }, 1000); return () => { window.removeEventListener('storage', handleStorageChange); clearInterval(checkInterval); }; }, []); // Load available paths for suggestions useEffect(() => { const loadPaths = async () => { try { // Get recent paths from localStorage const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); // Load common/home directory paths const response = await api.browseFilesystem(); const data = await response.json(); if (data.suggestions) { const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path })); const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths]; setPathList(allPaths); } else { setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); } } catch (error) { console.error('Error loading paths:', error); const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); } }; loadPaths(); }, []); // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing) useEffect(() => { const inputValue = newProjectPath.trim(); if (inputValue.length === 0) { setShowPathDropdown(false); return; } // Show dropdown when user starts typing setShowPathDropdown(true); const updateSuggestions = async () => { // First show filtered existing suggestions from pathList const staticFiltered = pathList.filter(pathItem => pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) || pathItem.path.toLowerCase().includes(inputValue.toLowerCase()) ); // Check if input looks like a directory path for dynamic browsing const isDirPath = inputValue.includes('/') && inputValue.length > 1; if (isDirPath) { try { let dirToSearch; // Determine which directory to search if (inputValue.endsWith('/')) { // User typed "/home/simos/" - search inside /home/simos dirToSearch = inputValue.slice(0, -1); } else { // User typed "/home/simos/con" - search inside /home/simos for items starting with "con" const lastSlashIndex = inputValue.lastIndexOf('/'); dirToSearch = inputValue.substring(0, lastSlashIndex); } // Only search if we have a valid directory path (not root only) if (dirToSearch && dirToSearch !== '') { const response = await api.browseFilesystem(dirToSearch); const data = await response.json(); if (data.suggestions) { // Filter directories that match the current input const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1); const dynamicPaths = data.suggestions .filter(suggestion => { const dirName = suggestion.name; return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true; }) .map(s => ({ name: s.name, path: s.path })) .slice(0, 8); // Combine static and dynamic suggestions, prioritize dynamic const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8); setFilteredPaths(combined); setSelectedPathIndex(-1); return; } } } catch (error) { console.debug('Dynamic browsing failed:', error.message); } } // Fallback to just static filtered suggestions setFilteredPaths(staticFiltered.slice(0, 8)); setSelectedPathIndex(-1); }; updateSuggestions(); }, [newProjectPath, pathList]); // Select path from dropdown (ChatInterface pattern) const selectPath = (pathItem) => { setNewProjectPath(pathItem.path); setShowPathDropdown(false); setSelectedPathIndex(-1); }; // Save path to recent paths const saveToRecentPaths = (path) => { try { const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10); localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths)); } catch (error) { console.error('Error saving recent paths:', error); } }; const toggleProject = (projectName) => { const newExpanded = new Set(); // If clicking the already-expanded project, collapse it (newExpanded stays empty) // If clicking a different project, expand only that one if (!expandedProjects.has(projectName)) { newExpanded.add(projectName); } setExpandedProjects(newExpanded); }; // Wrapper to attach project context when session is clicked const handleSessionClick = (session, projectName) => { onSessionSelect({ ...session, __projectName: projectName }); }; // Starred projects utility functions const toggleStarProject = (projectName) => { const newStarred = new Set(starredProjects); if (newStarred.has(projectName)) { newStarred.delete(projectName); } else { newStarred.add(projectName); } setStarredProjects(newStarred); // Persist to localStorage try { localStorage.setItem('starredProjects', JSON.stringify([...newStarred])); } catch (error) { console.error('Error saving starred projects:', error); } }; const isProjectStarred = (projectName) => { return starredProjects.has(projectName); }; // Helper function to get all sessions for a project (initial + additional) const getAllSessions = (project) => { // Combine Claude and Cursor sessions; Sidebar will display icon per row const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' })); const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' })); // Sort by most recent activity/date const normalizeDate = (s) => new Date(s.__provider === 'cursor' ? s.createdAt : s.lastActivity); return [...claudeSessions, ...cursorSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a)); }; // Helper function to get the last activity date for a project const getProjectLastActivity = (project) => { const allSessions = getAllSessions(project); if (allSessions.length === 0) { return new Date(0); // Return epoch date for projects with no sessions } // Find the most recent session activity const mostRecentDate = allSessions.reduce((latest, session) => { const sessionDate = new Date(session.lastActivity); return sessionDate > latest ? sessionDate : latest; }, new Date(0)); return mostRecentDate; }; // Combined sorting: starred projects first, then by selected order const sortedProjects = [...projects].sort((a, b) => { const aStarred = isProjectStarred(a.name); const bStarred = isProjectStarred(b.name); // First, sort by starred status if (aStarred && !bStarred) return -1; if (!aStarred && bStarred) return 1; // For projects with same starred status, sort by selected order if (projectSortOrder === 'date') { // Sort by most recent activity (descending) return getProjectLastActivity(b) - getProjectLastActivity(a); } else { // Sort by display name (user-defined) or fallback to name (ascending) const nameA = a.displayName || a.name; const nameB = b.displayName || b.name; return nameA.localeCompare(nameB); } }); const startEditing = (project) => { setEditingProject(project.name); setEditingName(project.displayName); }; const cancelEditing = () => { setEditingProject(null); setEditingName(''); }; const saveProjectName = async (projectName) => { try { const response = await api.renameProject(projectName, editingName); if (response.ok) { // Refresh projects to get updated data if (window.refreshProjects) { window.refreshProjects(); } else { window.location.reload(); } } else { console.error('Failed to rename project'); } } catch (error) { console.error('Error renaming project:', error); } setEditingProject(null); setEditingName(''); }; const deleteSession = async (projectName, sessionId) => { if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) { return; } try { const response = await api.deleteSession(projectName, sessionId); if (response.ok) { // Call parent callback if provided if (onSessionDelete) { onSessionDelete(sessionId); } } else { console.error('Failed to delete session'); alert('Failed to delete session. Please try again.'); } } catch (error) { console.error('Error deleting session:', error); alert('Error deleting session. Please try again.'); } }; const deleteProject = async (projectName) => { if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) { return; } try { const response = await api.deleteProject(projectName); if (response.ok) { // Call parent callback if provided if (onProjectDelete) { onProjectDelete(projectName); } } else { const error = await response.json(); console.error('Failed to delete project'); alert(error.error || 'Failed to delete project. Please try again.'); } } catch (error) { console.error('Error deleting project:', error); alert('Error deleting project. Please try again.'); } }; const createNewProject = async () => { if (!newProjectPath.trim()) { alert('Please enter a project path'); return; } setCreatingProject(true); try { const response = await api.createProject(newProjectPath.trim()); if (response.ok) { const result = await response.json(); // Save the path to recent paths before clearing saveToRecentPaths(newProjectPath.trim()); setShowNewProject(false); setNewProjectPath(''); // Refresh projects to show the new one if (window.refreshProjects) { window.refreshProjects(); } else { window.location.reload(); } } else { const error = await response.json(); alert(error.error || 'Failed to create project. Please try again.'); } } catch (error) { console.error('Error creating project:', error); alert('Error creating project. Please try again.'); } finally { setCreatingProject(false); } }; const cancelNewProject = () => { setShowNewProject(false); setNewProjectPath(''); }; const loadMoreSessions = async (project) => { // Check if we can load more sessions const canLoadMore = project.sessionMeta?.hasMore !== false; if (!canLoadMore || loadingSessions[project.name]) { return; } setLoadingSessions(prev => ({ ...prev, [project.name]: true })); try { const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0); const response = await api.sessions(project.name, 5, currentSessionCount); if (response.ok) { const result = await response.json(); // Store additional sessions locally setAdditionalSessions(prev => ({ ...prev, [project.name]: [ ...(prev[project.name] || []), ...result.sessions ] })); // Update project metadata if needed if (result.hasMore === false) { // Mark that there are no more sessions to load project.sessionMeta = { ...project.sessionMeta, hasMore: false }; } } } catch (error) { console.error('Error loading more sessions:', error); } finally { setLoadingSessions(prev => ({ ...prev, [project.name]: false })); } }; // Filter projects based on search input const filteredProjects = sortedProjects.filter(project => { if (!searchFilter.trim()) return true; const searchLower = searchFilter.toLowerCase(); const displayName = (project.displayName || project.name).toLowerCase(); const projectName = project.name.toLowerCase(); // Search in both display name and actual project name/path return displayName.includes(searchLower) || projectName.includes(searchLower); }); // Enhanced project selection that updates both the main UI and TaskMaster context const handleProjectSelect = (project) => { // Call the original project select handler onProjectSelect(project); // Update TaskMaster context with the selected project setCurrentProject(project); }; return (
{/* Header */}
{/* Desktop Header */}

Claude Code UI

AI coding assistant interface

{onToggleSidebar && ( )}
{/* Mobile Header */}

Claude Code UI

Projects

{/* New Project Form */} {showNewProject && (
{/* Desktop Form */}
Create New Project
setNewProjectPath(e.target.value)} placeholder="/path/to/project or relative/path" className="text-sm focus:ring-2 focus:ring-primary/20" autoFocus onKeyDown={(e) => { // Handle path dropdown navigation (ChatInterface pattern) if (showPathDropdown && filteredPaths.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedPathIndex(prev => prev < filteredPaths.length - 1 ? prev + 1 : 0 ); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedPathIndex(prev => prev > 0 ? prev - 1 : filteredPaths.length - 1 ); } else if (e.key === 'Enter') { e.preventDefault(); if (selectedPathIndex >= 0) { selectPath(filteredPaths[selectedPathIndex]); } else if (filteredPaths.length > 0) { selectPath(filteredPaths[0]); } else { createNewProject(); } return; } else if (e.key === 'Escape') { e.preventDefault(); setShowPathDropdown(false); return; } else if (e.key === 'Tab') { e.preventDefault(); if (selectedPathIndex >= 0) { selectPath(filteredPaths[selectedPathIndex]); } else if (filteredPaths.length > 0) { selectPath(filteredPaths[0]); } return; } } // Regular input handling if (e.key === 'Enter') { createNewProject(); } if (e.key === 'Escape') { cancelNewProject(); } }} /> {/* Path dropdown (ChatInterface pattern) */} {showPathDropdown && filteredPaths.length > 0 && (
{filteredPaths.map((pathItem, index) => (
{ e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectPath(pathItem); }} >
{pathItem.name}
{pathItem.path}
))}
)}
{/* Mobile Form - Simple Overlay */}

New Project

setNewProjectPath(e.target.value)} placeholder="/path/to/project or relative/path" className="text-sm h-10 rounded-md focus:border-primary transition-colors" autoFocus onKeyDown={(e) => { // Handle path dropdown navigation (same as desktop) if (showPathDropdown && filteredPaths.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedPathIndex(prev => prev < filteredPaths.length - 1 ? prev + 1 : 0 ); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedPathIndex(prev => prev > 0 ? prev - 1 : filteredPaths.length - 1 ); } else if (e.key === 'Enter') { e.preventDefault(); if (selectedPathIndex >= 0) { selectPath(filteredPaths[selectedPathIndex]); } else if (filteredPaths.length > 0) { selectPath(filteredPaths[0]); } else { createNewProject(); } return; } else if (e.key === 'Escape') { e.preventDefault(); setShowPathDropdown(false); return; } } // Regular input handling if (e.key === 'Enter') { createNewProject(); } if (e.key === 'Escape') { cancelNewProject(); } }} style={{ fontSize: '16px', // Prevents zoom on iOS WebkitAppearance: 'none' }} /> {/* Mobile Path dropdown */} {showPathDropdown && filteredPaths.length > 0 && (
{filteredPaths.map((pathItem, index) => (
{ e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectPath(pathItem); }} >
{pathItem.name}
{pathItem.path}
))}
)}
{/* Safe area for mobile */}
)} {/* Search Filter and Actions */} {projects.length > 0 && !isLoading && (
setSearchFilter(e.target.value)} className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20" /> {searchFilter && ( )}
{/* Action Buttons - Desktop only */} {!isMobile && (
)}
)} {/* Projects List */}
{isLoading ? (

Loading projects...

Fetching your Claude projects and sessions

) : projects.length === 0 ? (

No projects found

Run Claude CLI in a project directory to get started

) : filteredProjects.length === 0 ? (

No matching projects

Try adjusting your search term

) : ( filteredProjects.map((project) => { const isExpanded = expandedProjects.has(project.name); const isSelected = selectedProject?.name === project.name; const isStarred = isProjectStarred(project.name); return (
{/* Project Header */}
{/* Mobile Project Item */}
{ // On mobile, just toggle the folder - don't select the project toggleProject(project.name); }} onTouchEnd={handleTouchClick(() => toggleProject(project.name))} >
{isExpanded ? ( ) : ( )}
{editingProject === project.name ? ( setEditingName(e.target.value)} className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none" placeholder="Project name" autoFocus autoComplete="off" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') saveProjectName(project.name); if (e.key === 'Escape') cancelEditing(); }} style={{ fontSize: '16px', // Prevents zoom on iOS WebkitAppearance: 'none', borderRadius: '8px' }} /> ) : ( <>

{project.displayName}

{tasksEnabled && ( { const projectConfigured = project.taskmaster?.hasTaskmaster; const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured; if (projectConfigured && mcpConfigured) return 'fully-configured'; if (projectConfigured) return 'taskmaster-only'; if (mcpConfigured) return 'mcp-only'; return 'not-configured'; })()} size="xs" className="hidden md:inline-flex flex-shrink-0 ml-2" /> )}

{(() => { const sessionCount = getAllSessions(project).length; const hasMore = project.sessionMeta?.hasMore !== false; const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount; return `${count} session${count === 1 ? '' : 's'}`; })()}

)}
{editingProject === project.name ? ( <> ) : ( <> {/* Star button */} {getAllSessions(project).length === 0 && ( )}
{isExpanded ? ( ) : ( )}
)}
{/* Desktop Project Item */}
{/* Sessions List */} {isExpanded && (
{!initialSessionsLoaded.has(project.name) ? ( // Loading skeleton for sessions Array.from({ length: 3 }).map((_, i) => (
)) ) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (

No sessions yet

) : ( getAllSessions(project).map((session) => { // Handle both Claude and Cursor session formats const isCursorSession = session.__provider === 'cursor'; // Calculate if session is active (within last 10 minutes) const sessionDate = new Date(isCursorSession ? session.createdAt : session.lastActivity); const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60)); const isActive = diffInMinutes < 10; // Get session display values const sessionName = isCursorSession ? (session.name || 'Untitled Session') : (session.summary || 'New Session'); const sessionTime = isCursorSession ? session.createdAt : session.lastActivity; const messageCount = session.messageCount || 0; return (
{/* Active session indicator dot */} {isActive && (
)} {/* Mobile Session Item */}
{ handleProjectSelect(project); handleSessionClick(session, project.name); }} onTouchEnd={handleTouchClick(() => { handleProjectSelect(project); handleSessionClick(session, project.name); })} >
{isCursorSession ? ( ) : ( )}
{sessionName}
{formatTimeAgo(sessionTime, currentTime)} {messageCount > 0 && ( {messageCount} )} {/* Provider tiny icon */} {isCursorSession ? ( ) : ( )}
{/* Mobile delete button - only for Claude sessions */} {!isCursorSession && ( )}
{/* Desktop Session Item */}
{/* Desktop hover buttons - only for Claude sessions */} {!isCursorSession && (
{editingSession === session.id ? ( <> setEditingSessionName(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') { updateSessionSummary(project.name, session.id, editingSessionName); } else if (e.key === 'Escape') { setEditingSession(null); setEditingSessionName(''); } }} onClick={(e) => e.stopPropagation()} className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary" autoFocus /> ) : ( <> {/* Generate summary button */} {/* */} {/* Edit button */} {/* Delete button */} )}
)}
); }) )} {/* Show More Sessions Button */} {getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && ( )} {/* New Session Button */}
)}
); }) )}
{/* Version Update Notification */} {updateAvailable && (
{/* Desktop Version Notification */}
{/* Mobile Version Notification */}
)} {/* Settings Section */}
{/* Mobile Settings */}
{/* Desktop Settings */}
); } export default Sidebar;