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 (
AI coding assistant interface
Projects
Fetching your Claude projects and sessions
Run Claude CLI in a project directory to get started
Try adjusting your search term
{(() => { const sessionCount = getAllSessions(project).length; const hasMore = project.sessionMeta?.hasMore !== false; const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount; return `${count} session${count === 1 ? '' : 's'}`; })()}
> )}No sessions yet