mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-15 05:29:36 +00:00
first commit
This commit is contained in:
974
src/components/Sidebar.jsx
Normal file
974
src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,974 @@
|
||||
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 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
// 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
|
||||
}) {
|
||||
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 [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Touch handler to prevent double-tap issues on iPad
|
||||
const handleTouchClick = (callback) => {
|
||||
return (e) => {
|
||||
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]);
|
||||
|
||||
const toggleProject = (projectName) => {
|
||||
const newExpanded = new Set(expandedProjects);
|
||||
if (newExpanded.has(projectName)) {
|
||||
newExpanded.delete(projectName);
|
||||
} else {
|
||||
newExpanded.add(projectName);
|
||||
}
|
||||
setExpandedProjects(newExpanded);
|
||||
};
|
||||
|
||||
|
||||
const startEditing = (project) => {
|
||||
setEditingProject(project.name);
|
||||
setEditingName(project.displayName);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingProject(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const saveProjectName = async (projectName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectName}/rename`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName: 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 fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
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 fetch(`/api/projects/${projectName}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
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 fetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: 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 fetch(
|
||||
`/api/projects/${project.name}/sessions?limit=5&offset=${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 }));
|
||||
}
|
||||
};
|
||||
|
||||
// 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];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card md:select-none">
|
||||
{/* Header */}
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh projects and sessions (Ctrl+R)"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
title="Create new project (Ctrl+N)"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div className="md:hidden p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
|
||||
<p className="text-sm text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-md bg-primary text-primary-foreground flex items-center justify-center active:scale-95 transition-all duration-150"
|
||||
onClick={() => setShowNewProject(true)}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Project Form */}
|
||||
{showNewProject && (
|
||||
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
|
||||
{/* Desktop Form */}
|
||||
<div className="hidden md:block space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Create New Project
|
||||
</div>
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create Project'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="h-8 text-xs hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Form - Simple Overlay */}
|
||||
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
||||
<FolderPlus className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">New Project</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
variant="outline"
|
||||
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safe area for mobile */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects List */}
|
||||
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
|
||||
<div className="md:space-y-1 pb-safe-area-inset-bottom">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching your Claude projects and sessions
|
||||
</p>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No projects found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Claude CLI in a project directory to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const isExpanded = expandedProjects.has(project.name);
|
||||
const isSelected = selectedProject?.name === project.name;
|
||||
|
||||
return (
|
||||
<div key={project.name} className="md:space-y-1">
|
||||
{/* Project Header */}
|
||||
<div className="group md:group">
|
||||
{/* Mobile Project Item */}
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
|
||||
isSelected && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
// On mobile, just toggle the folder - don't select the project
|
||||
toggleProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => toggleProject(project.name))}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
|
||||
isExpanded ? "bg-primary/10" : "bg-muted"
|
||||
)}>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingProject === project.name ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => 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'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{project.displayName}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
const hasMore = project.sessionMeta?.hasMore !== false;
|
||||
const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
|
||||
return `${count} session${count === 1 ? '' : 's'}`;
|
||||
})()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{editingProject === project.name ? (
|
||||
<>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-green-500 dark:bg-green-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
saveProjectName(project.name);
|
||||
}}
|
||||
>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-gray-500 dark:bg-gray-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEditing();
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 transition-all duration-150 border border-red-200 dark:border-red-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 transition-all duration-150 border border-primary/20 dark:border-primary/30"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing(project);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => startEditing(project))}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-primary" />
|
||||
</button>
|
||||
<div className="w-6 h-6 rounded-md bg-muted/30 flex items-center justify-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Project Item */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50 transition-colors duration-200",
|
||||
isSelected && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Desktop behavior: select project and toggle
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
{editingProject === project.name ? (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="Project name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveProjectName(project.name);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground truncate" title={project.fullPath}>
|
||||
{project.fullPath}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-semibold truncate text-foreground" title={project.displayName}>
|
||||
{project.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
const hasMore = project.sessionMeta?.hasMore !== false;
|
||||
return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
|
||||
})()}
|
||||
{project.fullPath !== project.displayName && (
|
||||
<span className="ml-1 opacity-60" title={project.fullPath}>
|
||||
• {project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{editingProject === project.name ? (
|
||||
<>
|
||||
<div
|
||||
className="w-6 h-6 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 flex items-center justify-center rounded cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
saveProjectName(project.name);
|
||||
}}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
</div>
|
||||
<div
|
||||
className="w-6 h-6 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center justify-center rounded cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEditing();
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing(project);
|
||||
}}
|
||||
title="Rename project (F2)"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</div>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<div
|
||||
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
}}
|
||||
title="Delete empty project (Delete)"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
{isExpanded && (
|
||||
<div className="ml-3 space-y-1 border-l border-border pl-3">
|
||||
{!initialSessionsLoaded.has(project.name) ? (
|
||||
// Loading skeleton for sessions
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="p-2 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-3 h-3 bg-muted rounded-full animate-pulse mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-3 bg-muted rounded animate-pulse" style={{ width: `${60 + i * 15}%` }} />
|
||||
<div className="h-2 bg-muted rounded animate-pulse w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
|
||||
<div className="py-2 px-3 text-left">
|
||||
<p className="text-xs text-muted-foreground">No sessions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
getAllSessions(project).map((session) => (
|
||||
<div key={session.id} className="group relative">
|
||||
{/* Mobile Session Item */}
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 mx-3 my-0.5 rounded-md bg-card border border-border/30 active:scale-[0.98] transition-all duration-150 relative",
|
||||
selectedSession?.id === session.id && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
onProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
|
||||
)}>
|
||||
<MessageSquare className={cn(
|
||||
"w-3 h-3",
|
||||
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate text-foreground">
|
||||
{session.summary || 'New Session'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||
{session.messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile delete button */}
|
||||
<button
|
||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Session Item */}
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
|
||||
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => onSessionSelect(session)}
|
||||
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||||
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium truncate text-foreground">
|
||||
{session.summary || 'New Session'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(session.lastActivity, currentTime)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
|
||||
{session.messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Desktop delete button */}
|
||||
<button
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
}}
|
||||
title="Delete session (Delete)"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Show More Sessions Button */}
|
||||
{getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-center gap-2 mt-2 text-muted-foreground"
|
||||
onClick={() => loadMoreSessions(project)}
|
||||
disabled={loadingSessions[project.name]}
|
||||
>
|
||||
{loadingSessions[project.name] ? (
|
||||
<>
|
||||
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Show more sessions
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* New Session Button */}
|
||||
<div className="md:hidden px-3 pb-2">
|
||||
<button
|
||||
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
onNewSession(project);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="hidden md:flex w-full justify-start gap-2 mt-1 h-8 text-xs font-medium bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
|
||||
onClick={() => onNewSession(project)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Settings Section */}
|
||||
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
|
||||
{/* Mobile Settings */}
|
||||
<div className="md:hidden p-4 pb-20 border-t border-border/50">
|
||||
<button
|
||||
className="w-full h-14 bg-muted/50 hover:bg-muted/70 rounded-2xl flex items-center justify-start gap-4 px-4 active:scale-[0.98] transition-all duration-150"
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-medium text-foreground">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Settings */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hidden md:flex w-full justify-start gap-2 p-2 h-auto font-normal text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
<span className="text-xs">Tools Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user