import React, { useState, useEffect } 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 { api } from '../utils/api'; // 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, onShowVersionModal }) { 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(''); // 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-tools-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-tools-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); }; }, []); const toggleProject = (projectName) => { const newExpanded = new Set(expandedProjects); if (newExpanded.has(projectName)) { newExpanded.delete(projectName); } else { newExpanded.add(projectName); } setExpandedProjects(newExpanded); }; // 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) => { const initialSessions = project.sessions || []; const additional = additionalSessions[project.name] || []; return [...initialSessions, ...additional]; }; // 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(); 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); }); return (
{/* Header */}
{/* Desktop Header */}

Claude Code UI

AI coding assistant interface

{/* 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) => { if (e.key === 'Enter') createNewProject(); if (e.key === 'Escape') cancelNewProject(); }} />
{/* 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) => { if (e.key === 'Enter') createNewProject(); if (e.key === 'Escape') cancelNewProject(); }} />
{/* Safe area for mobile */}
)} {/* Search Filter */} {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 && ( )}
)} {/* 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}

{(() => { 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) => { // Calculate if session is active (within last 10 minutes) const sessionDate = new Date(session.lastActivity); const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60)); const isActive = diffInMinutes < 10; return (
{/* Active session indicator dot */} {isActive && (
)} {/* Mobile Session Item */}
{ onProjectSelect(project); onSessionSelect(session); }} onTouchEnd={handleTouchClick(() => { onProjectSelect(project); onSessionSelect(session); })} >
{session.summary || 'New Session'}
{formatTimeAgo(session.lastActivity, currentTime)} {session.messageCount > 0 && ( {session.messageCount} )}
{/* Mobile delete button */}
{/* Desktop Session Item */}
{/* Desktop hover buttons */}
{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;