diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index bd38f4a..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,695 +0,0 @@ -/* - * App.jsx - Main Application Component with Session Protection System - * - * SESSION PROTECTION SYSTEM OVERVIEW: - * =================================== - * - * Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages - * during active conversations, creating a poor user experience. - * - * Solution: Track "active sessions" and pause project updates during conversations. - * - * How it works: - * 1. When user sends message → session marked as "active" - * 2. Project updates are skipped while session is active - * 3. When conversation completes/aborts → session marked as "inactive" - * 4. Project updates resume normally - * - * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). - */ - -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; -import Sidebar from './components/Sidebar'; -import MainContent from './components/MainContent'; -import MobileNav from './components/MobileNav'; -import Settings from './components/Settings'; -import QuickSettingsPanel from './components/QuickSettingsPanel'; - -import { ThemeProvider } from './contexts/ThemeContext'; -import { AuthProvider } from './contexts/AuthContext'; -import { TaskMasterProvider } from './contexts/TaskMasterContext'; -import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; -import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext'; -import ProtectedRoute from './components/ProtectedRoute'; -import { useDeviceSettings } from './hooks/useDeviceSettings'; -import { api, authenticatedFetch } from './utils/api'; -import { I18nextProvider, useTranslation } from 'react-i18next'; -import i18n from './i18n/config.js'; - -// TODO: Move to a separate file called AppContent.ts -// Main App component with routing -function AppContent() { - const navigate = useNavigate(); // used for navigation on project select - const { sessionId } = useParams(); - const { t } = useTranslation('common'); - // * This is a tracker for avoiding excessive re-renders during development - const renderCountRef = useRef(0); - // console.log(`AppContent render count: ${renderCountRef.current++}`); - - // ! ESSENTIAL STATES - const [projects, setProjects] = useState([]); - // debugger; - // console.log('Projects state updated:', projects); // Debug log to track projects state changes - const [selectedProject, setSelectedProject] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - - - const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files' - const { isMobile } = useDeviceSettings({ trackPWA: false }); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [isLoadingProjects, setIsLoadingProjects] = useState(true); - const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject } - const [isInputFocused, setIsInputFocused] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); - // Session Protection System: Track sessions with active conversations to prevent - // automatic project updates from interrupting ongoing chats. When a user sends - // a message, the session is marked as "active" and project updates are paused - // until the conversation completes or is aborted. - const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations - - // Processing Sessions: Track which sessions are currently thinking/processing - // This allows us to restore the "Thinking..." banner when switching back to a processing session - const [processingSessions, setProcessingSessions] = useState(new Set()); - - // External Message Update Trigger: Incremented when external CLI modifies current session's JSONL - // Triggers ChatInterface to reload messages without switching sessions - const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); - - const { ws, sendMessage, latestMessage } = useWebSocket(); - console.log('WebSocket latest message:', latestMessage); // Debug log to track WebSocket messages - // Ref to track loading progress timeout for cleanup - const loadingProgressTimeoutRef = useRef(null); - - useEffect(() => { - // Fetch projects on component mount - fetchProjects(); - }, []); - - // Helper function to determine if an update is purely additive (new sessions/projects) - // vs modifying existing selected items that would interfere with active conversations - const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => { - if (!selectedProject || !selectedSession) { - // No active session to protect, allow all updates - return true; - } - - // Find the selected project in both current and updated data - const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name); - const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name); - - if (!currentSelectedProject || !updatedSelectedProject) { - // Project structure changed significantly, not purely additive - return false; - } - - // Find the selected session in both current and updated project data - const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id); - const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); - - if (!currentSelectedSession || !updatedSelectedSession) { - // Selected session was deleted or significantly changed, not purely additive - return false; - } - - // Check if the selected session's content has changed (modification vs addition) - // Compare key fields that would affect the loaded chat interface - const sessionUnchanged = - currentSelectedSession.id === updatedSelectedSession.id && - currentSelectedSession.title === updatedSelectedSession.title && - currentSelectedSession.created_at === updatedSelectedSession.created_at && - currentSelectedSession.updated_at === updatedSelectedSession.updated_at; - - // This is considered additive if the selected session is unchanged - // (new sessions may have been added elsewhere, but active session is protected) - return sessionUnchanged; - }; - - // Handle WebSocket messages for real-time project updates - useEffect(() => { - if (latestMessage) { - // Handle loading progress updates - if (latestMessage.type === 'loading_progress') { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - setLoadingProgress(latestMessage); - if (latestMessage.phase === 'complete') { - loadingProgressTimeoutRef.current = setTimeout(() => { - setLoadingProgress(null); - loadingProgressTimeoutRef.current = null; - }, 500); - } - return; - } - - if (latestMessage.type === 'projects_updated') { - - // External Session Update Detection: Check if the changed file is the current session's JSONL - // If so, and the session is not active, trigger a message reload in ChatInterface - if (latestMessage.changedFile && selectedSession && selectedProject) { - // Extract session ID from changedFile (format: "project-name/session-id.jsonl") - const normalized = latestMessage.changedFile.replace(/\\/g, '/'); - const changedFileParts = normalized.split('/'); - - if (changedFileParts.length >= 2) { - const filename = changedFileParts[changedFileParts.length - 1]; - const changedSessionId = filename.replace('.jsonl', ''); - - // Check if this is the currently-selected session - if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - // Session is not active - safe to reload messages - setExternalMessageUpdate(prev => prev + 1); - } - } - } - } - - // Session Protection Logic: Allow additions but prevent changes during active conversations - // This allows new sessions/projects to appear in sidebar while protecting active chat messages - // We check for two types of active sessions: - // 1. Existing sessions: selectedSession.id exists in activeSessions - // 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received) - const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) || - (activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-'))); - - if (hasActiveSession) { - // Allow updates but be selective: permit additions, prevent changes to existing items - const updatedProjects = latestMessage.projects; - const currentProjects = projects; - - // Check if this is purely additive (new sessions/projects) vs modification of existing ones - const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession); - - if (!isAdditiveUpdate) { - // Skip updates that would modify existing selected session/project - return; - } - // Continue with additive updates below - } - - // Update projects state with the new data from WebSocket - const updatedProjects = latestMessage.projects; - console.log("====> latest message is: ", latestMessage); - setProjects(updatedProjects); - - // Update selected project if it exists in the updated projects - if (selectedProject) { - const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name); - if (updatedSelectedProject) { - // Only update selected project if it actually changed - prevents flickering - if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(updatedSelectedProject); - } - - if (selectedSession) { - const allSessions = [ - ...(updatedSelectedProject.sessions || []), - ...(updatedSelectedProject.codexSessions || []), - ...(updatedSelectedProject.cursorSessions || []) - ]; - const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id); - if (!updatedSelectedSession) { - setSelectedSession(null); - } - } - } - } - } - } - - return () => { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - }; - }, [latestMessage, selectedProject, selectedSession, activeSessions]); - - const fetchProjects = async () => { - try { - setIsLoadingProjects(true); - const response = await api.projects(); - const data = await response.json(); - - // Always fetch Cursor sessions for each project so we can combine views - for (let project of data) { - try { - const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`; - const cursorResponse = await authenticatedFetch(url); - if (cursorResponse.ok) { - const cursorData = await cursorResponse.json(); - if (cursorData.success && cursorData.sessions) { - project.cursorSessions = cursorData.sessions; - } else { - project.cursorSessions = []; - } - } else { - project.cursorSessions = []; - } - } catch (error) { - console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); - project.cursorSessions = []; - } - } - - // Optimize to preserve object references when data hasn't changed - setProjects(prevProjects => { - // If no previous projects, just set the new data - if (prevProjects.length === 0) { - return data; - } - - console.log("===> Prev projects: ", prevProjects); - - // Check if the projects data has actually changed - const hasChanges = data.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - // Compare key properties that would affect UI - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) || - JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions) - ); - }) || data.length !== prevProjects.length; - - // Only update if there are actual changes - return hasChanges ? data : prevProjects; - }); - - // Don't auto-select any project - user should choose manually - } catch (error) { - console.error('Error fetching projects:', error); - } finally { - setIsLoadingProjects(false); - } - }; - - // Expose fetchProjects globally for component access - window.refreshProjects = fetchProjects; // ! Exposing it globally is bad so we should use props for stuff like this. - - // Expose openSettings function globally for component access - window.openSettings = useCallback((tab = 'tools') => { - setSettingsInitialTab(tab); - setShowSettings(true); - }, []); // ! Exposing it globally is bad so we should use props for stuff like this. - - // Handle URL-based session loading - useEffect(() => { - if (sessionId && projects.length > 0) { - // Only switch tabs on initial load, not on every project update - const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; - // Find the session across all projects - for (const project of projects) { - let session = project.sessions?.find(s => s.id === sessionId); - if (session) { - setSelectedProject(project); - setSelectedSession({ ...session, __provider: 'claude' }); - // Only switch to chat tab if we're loading a different session - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - // Also check Cursor sessions - const cSession = project.cursorSessions?.find(s => s.id === sessionId); - if (cSession) { - setSelectedProject(project); - setSelectedSession({ ...cSession, __provider: 'cursor' }); - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - } - - // If session not found, it might be a newly created session - // Just navigate to it and it will be found when the sidebar refreshes - // Don't redirect to home, let the session load naturally - } - }, [sessionId, projects, navigate]); - - - // TODO: All this functions should be in separate components - const handleProjectSelect = (project) => { - setSelectedProject(project); - setSelectedSession(null); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionSelect = (session) => { - setSelectedSession(session); - // Only switch to chat tab when user explicitly selects a session - // This prevents tab switching during automatic updates - if (activeTab !== 'git' && activeTab !== 'preview') { - setActiveTab('chat'); - } - - // For Cursor sessions, we need to set the session ID differently - // since they're persistent and not created by Claude - const provider = localStorage.getItem('selected-provider') || 'claude'; - if (provider === 'cursor') { - // Cursor sessions have persistent IDs - sessionStorage.setItem('cursorSessionId', session.id); - } - - // Only close sidebar on mobile if switching to a different project - if (isMobile) { - const sessionProjectName = session.__projectName; - const currentProjectName = selectedProject?.name; - - // Close sidebar if clicking a session from a different project - // Keep it open if clicking a session from the same project - if (sessionProjectName !== currentProjectName) { - setSidebarOpen(false); - } - } - navigate(`/session/${session.id}`); - }; - - const handleNewSession = (project) => { - setSelectedProject(project); - setSelectedSession(null); - setActiveTab('chat'); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionDelete = (sessionId) => { - // If the deleted session was currently selected, clear it - if (selectedSession?.id === sessionId) { - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.map(project => ({ - ...project, - sessions: project.sessions?.filter(session => session.id !== sessionId) || [], - sessionMeta: { - ...project.sessionMeta, - total: Math.max(0, (project.sessionMeta?.total || 0) - 1) - } - })) - ); - }; - - - - const handleSidebarRefresh = async () => { - // Refresh only the sessions for all projects, don't change selected state - try { - const response = await api.projects(); - const freshProjects = await response.json(); - - // Optimize to preserve object references and minimize re-renders - setProjects(prevProjects => { - // Check if projects data has actually changed - const hasChanges = freshProjects.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) - ); - }) || freshProjects.length !== prevProjects.length; - - return hasChanges ? freshProjects : prevProjects; - }); - - // If we have a selected project, make sure it's still selected after refresh - if (selectedProject) { - const refreshedProject = freshProjects.find(p => p.name === selectedProject.name); - if (refreshedProject) { - // Only update selected project if it actually changed - if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(refreshedProject); - } - - // If we have a selected session, try to find it in the refreshed project - if (selectedSession) { - const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id); - if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) { - setSelectedSession(refreshedSession); - } - } - } - } - } catch (error) { - console.error('Error refreshing sidebar:', error); - } - }; - - const handleProjectDelete = (projectName) => { - // If the deleted project was currently selected, clear it - if (selectedProject?.name === projectName) { - setSelectedProject(null); - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.filter(project => project.name !== projectName) - ); - }; - - // Session Protection Functions: Manage the lifecycle of active sessions - - // markSessionAsActive: Called when user sends a message to mark session as protected - // This includes both real session IDs and temporary "new-session-*" identifiers - const markSessionAsActive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates - const markSessionAsInactive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // Processing Session Functions: Track which sessions are currently thinking/processing - - // markSessionAsProcessing: Called when Claude starts thinking/processing - const markSessionAsProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsNotProcessing: Called when Claude finishes thinking/processing - const markSessionAsNotProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions - // Removes temporary "new-session-*" identifiers and adds the real session ID - // This maintains protection continuity during the transition from temporary to real session - const replaceTemporarySession = useCallback((realSessionId) => { - if (realSessionId) { - setActiveSessions(prev => { - const newSet = new Set(); - // Keep all non-temporary sessions and add the real session ID - for (const sessionId of prev) { - if (!sessionId.startsWith('new-session-')) { - newSet.add(sessionId); - } - } - newSet.add(realSessionId); - return newSet; - }); - } - }, []); - - const handleShowSettings = useCallback(() => { - setShowSettings(true); - }, []); - - const sidebarSharedProps = useMemo(() => ({ - projects, - selectedProject, - selectedSession, - onProjectSelect: handleProjectSelect, - onSessionSelect: handleSessionSelect, - onNewSession: handleNewSession, - onSessionDelete: handleSessionDelete, - onProjectDelete: handleProjectDelete, - isLoading: isLoadingProjects, - loadingProgress, - onRefresh: handleSidebarRefresh, - onShowSettings: handleShowSettings, - isMobile - }), [ - projects, - selectedProject, - selectedSession, - handleProjectSelect, - handleSessionSelect, - handleNewSession, - handleSessionDelete, - handleProjectDelete, - isLoadingProjects, - loadingProgress, - handleSidebarRefresh, - handleShowSettings, - isMobile - ]); - - - return ( -
- {/* Fixed Desktop Sidebar */} - {!isMobile && ( -
- -
- )} - - {/* Mobile Sidebar Overlay */} - {isMobile && ( -
-
- )} - - {/* Main Content Area - Flexible */} -
- setSidebarOpen(true)} - isLoading={isLoadingProjects} - onInputFocusChange={setIsInputFocused} - onSessionActive={markSessionAsActive} - onSessionInactive={markSessionAsInactive} - onSessionProcessing={markSessionAsProcessing} - onSessionNotProcessing={markSessionAsNotProcessing} - processingSessions={processingSessions} - onReplaceTemporarySession={replaceTemporarySession} - onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)} - onShowSettings={() => setShowSettings(true)} - - externalMessageUpdate={externalMessageUpdate} - /> -
- - {/* Mobile Bottom Navigation */} - {isMobile && ( - - )} - {/* Quick Settings Panel - Only show on chat tab */} - {activeTab === 'chat' && ( - - )} - - {/* // TODO: This should be in its own file. In modals/Settings.tsx */} - {/* // TODO: This should be in sidebar as well. */} - {/* Settings Modal */} - setShowSettings(false)} - projects={projects} - initialTab={settingsInitialTab} - /> -
- ); -} - -// Root App component with router -function App() { - return ( - - - - - - - - - - {/* // TODO: Can this be refactored to just have one route? */} - } /> - } /> - - - - - - - - - - ); -} - -export default App; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8593236 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { AuthProvider } from './contexts/AuthContext'; +import { TaskMasterProvider } from './contexts/TaskMasterContext'; +import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; +import { WebSocketProvider } from './contexts/WebSocketContext'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppContent from './components/app/AppContent'; +import i18n from './i18n/config.js'; + +export default function App() { + return ( + + + + + + + + + + } /> + } /> + + + + + + + + + + ); +} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index d3d7bea..93a04d2 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -29,6 +29,8 @@ import ClaudeLogo from './ClaudeLogo.jsx'; import CursorLogo from './CursorLogo.jsx'; import CodexLogo from './CodexLogo.jsx'; import NextTaskBanner from './NextTaskBanner.jsx'; +import QuickSettingsPanel from './QuickSettingsPanel'; + import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTranslation } from 'react-i18next'; @@ -5685,6 +5687,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, late + + ); } diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx index d2c8544..391d183 100644 --- a/src/components/QuickSettingsPanel.jsx +++ b/src/components/QuickSettingsPanel.jsx @@ -22,8 +22,10 @@ import { useUiPreferences } from '../hooks/useUiPreferences'; import { useTheme } from '../contexts/ThemeContext'; import LanguageSelector from './LanguageSelector'; +import { useDeviceSettings } from '../hooks/useDeviceSettings'; -const QuickSettingsPanel = ({ isMobile }) => { + +const QuickSettingsPanel = () => { const { t } = useTranslation('settings'); const [ isOpen, setIsOpen ] = useState(false); const [localIsOpen, setLocalIsOpen] = useState(false); // ! Is this necessary? Can we just use isOpen? @@ -32,6 +34,8 @@ const QuickSettingsPanel = ({ isMobile }) => { }); const { isDarkMode } = useTheme(); + const { isMobile } = useDeviceSettings({ trackPWA: false }); + const { preferences, setPreference } = useUiPreferences(); const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx deleted file mode 100644 index 206f9a0..0000000 --- a/src/components/Sidebar.jsx +++ /dev/null @@ -1,1554 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import { ScrollArea } from './ui/scroll-area'; -import { Button } from './ui/button'; -import { Badge } from './ui/badge'; -import { Input } from './ui/input'; -import { useTranslation } from 'react-i18next'; - -import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react'; -import { cn } from '../lib/utils'; -import ClaudeLogo from './ClaudeLogo'; -import CursorLogo from './CursorLogo.jsx'; -import CodexLogo from './CodexLogo.jsx'; -import TaskIndicator from './TaskIndicator'; -import ProjectCreationWizard from './ProjectCreationWizard'; -import VersionUpgradeModal from './modals/VersionUpgradeModal'; -import { useDeviceSettings } from '../hooks/useDeviceSettings'; -import { useVersionCheck } from '../hooks/useVersionCheck'; -import { useUiPreferences } from '../hooks/useUiPreferences'; -import { api } from '../utils/api'; -import { useTaskMaster } from '../contexts/TaskMasterContext'; -import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import { IS_PLATFORM } from '../constants/config'; - -import { formatTimeAgo } from '../utils/dateUtils'; - -function Sidebar({ - projects, - selectedProject, - selectedSession, - onProjectSelect, - onSessionSelect, - onNewSession, - onSessionDelete, - onProjectDelete, - isLoading, - loadingProgress, - onRefresh, - onShowSettings, - isMobile -}) { - const { t } = useTranslation(['sidebar', 'common']); - const { isPWA } = useDeviceSettings({ trackMobile: false }); - const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui'); - const { preferences, setPreference } = useUiPreferences(); - const { sidebarVisible } = preferences; - const isSidebarCollapsed = !isMobile && !sidebarVisible; - const [expandedProjects, setExpandedProjects] = useState(new Set()); - const [editingProject, setEditingProject] = useState(null); - const [showNewProject, setShowNewProject] = useState(false); - const [editingName, setEditingName] = useState(''); - 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 [searchFilter, setSearchFilter] = useState(''); - const [deletingProjects, setDeletingProjects] = useState(new Set()); - const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount } - const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider } - const [showVersionModal, setShowVersionModal] = useState(false); - - // 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(); - }; - }; - - useEffect(() => { - if (typeof document === 'undefined') { - return; - } - - document.documentElement.classList.toggle('pwa-mode', isPWA); - document.body.classList.toggle('pwa-mode', isPWA); - }, [isPWA]); - - // 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); - }; - }, []); - - - 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, Cursor, and Codex 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' })); - const codexSessions = (project.codexSessions || []).map(s => ({ ...s, __provider: 'codex' })); - // Sort by most recent activity/date - const normalizeDate = (s) => { - if (s.__provider === 'cursor') return new Date(s.createdAt); - if (s.__provider === 'codex') return new Date(s.createdAt || s.lastActivity); - return new Date(s.lastActivity); - }; - return [...claudeSessions, ...cursorSessions, ...codexSessions].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 showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => { - setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider }); - }; - - const confirmDeleteSession = async () => { - if (!sessionDeleteConfirmation) return; - - const { projectName, sessionId, provider } = sessionDeleteConfirmation; - setSessionDeleteConfirmation(null); - - try { - console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider }); - - // Call the appropriate API based on provider - let response; - if (provider === 'codex') { - response = await api.deleteCodexSession(sessionId); - } else { - response = await api.deleteSession(projectName, sessionId); - } - - console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status }); - - if (response.ok) { - console.log('[Sidebar] Session deleted successfully, calling callback'); - // Call parent callback if provided - if (onSessionDelete) { - onSessionDelete(sessionId); - } else { - console.warn('[Sidebar] No onSessionDelete callback provided'); - } - } else { - const errorText = await response.text(); - console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText }); - alert(t('messages.deleteSessionFailed')); - } - } catch (error) { - console.error('[Sidebar] Error deleting session:', error); - alert(t('messages.deleteSessionError')); - } - }; - - const deleteProject = (project) => { - const sessionCount = getAllSessions(project).length; - setDeleteConfirmation({ project, sessionCount }); - }; - - const confirmDeleteProject = async () => { - if (!deleteConfirmation) return; - - const { project, sessionCount } = deleteConfirmation; - const isEmpty = sessionCount === 0; - - setDeleteConfirmation(null); - setDeletingProjects(prev => new Set([...prev, project.name])); - - try { - const response = await api.deleteProject(project.name, !isEmpty); - - if (response.ok) { - if (onProjectDelete) { - onProjectDelete(project.name); - } - } else { - const error = await response.json(); - console.error('Failed to delete project'); - alert(error.error || t('messages.deleteProjectFailed')); - } - } catch (error) { - console.error('Error deleting project:', error); - alert(t('messages.deleteProjectError')); - } finally { - setDeletingProjects(prev => { - const next = new Set(prev); - next.delete(project.name); - return next; - }); - } - }; - - 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); - }; - - const handleCollapseSidebar = () => { - setPreference('sidebarVisible', false); - }; - - const handleExpandSidebar = () => { - setPreference('sidebarVisible', true); - }; - - const collapsedSidebar = ( -
- - - - - {updateAvailable && ( - - )} -
- ); - - return ( - <> - {isSidebarCollapsed ? ( - collapsedSidebar - ) : ( - <> - {/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */} - {showNewProject && ReactDOM.createPortal( - setShowNewProject(false)} - onProjectCreated={(project) => { - // Refresh projects list after creation - if (window.refreshProjects) { - window.refreshProjects(); - } else { - window.location.reload(); - } - }} - />, - document.body - )} - - {/* Delete Confirmation Modal */} - {deleteConfirmation && ReactDOM.createPortal( -
-
-
-
-
- -
-
-

- {t('deleteConfirmation.deleteProject')} -

-

- {t('deleteConfirmation.confirmDelete')}{' '} - - {deleteConfirmation.project.displayName || deleteConfirmation.project.name} - ? -

- {deleteConfirmation.sessionCount > 0 && ( -
-

- {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })} -

-

- {t('deleteConfirmation.allConversationsDeleted')} -

-
- )} -

- {t('deleteConfirmation.cannotUndo')} -

-
-
-
-
- - -
-
-
, - document.body - )} - - {/* Session Delete Confirmation Modal */} - {sessionDeleteConfirmation && ReactDOM.createPortal( -
-
-
-
-
- -
-
-

- {t('deleteConfirmation.deleteSession')} -

-

- {t('deleteConfirmation.confirmDelete')}{' '} - - {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')} - ? -

-

- {t('deleteConfirmation.cannotUndo')} -

-
-
-
-
- - -
-
-
, - document.body - )} - -
- {/* Header */} -
- {/* Desktop Header */} -
- {IS_PLATFORM ? ( - -
- -
-
-

{t('app.title')}

-

{t('app.subtitle')}

-
-
- ) : ( -
-
- -
-
-

{t('app.title')}

-

{t('app.subtitle')}

-
-
- )} - -
- - {/* Mobile Header */} -
-
- {IS_PLATFORM ? ( - -
- -
-
-

{t('app.title')}

-

{t('projects.title')}

-
-
- ) : ( -
-
- -
-
-

{t('app.title')}

-

{t('projects.title')}

-
-
- )} -
- - -
-
-
-
- - {/* Action Buttons - Desktop only - Always show when not loading */} - {!isLoading && !isMobile && ( -
-
- - -
-
- )} - - {/* Search Filter - Only show when there are projects */} - {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 ? ( -
-
-
-
-

{t('projects.loadingProjects')}

-

- {t('projects.fetchingProjects')} -

-

{t('projects.loadingProjects')}

- {loadingProgress && loadingProgress.total > 0 ? ( -
-
-
-
-

- {loadingProgress.current}/{loadingProgress.total} {t('projects.projects')} -

- {loadingProgress.currentProject && ( -

- {loadingProgress.currentProject.split('-').slice(-2).join('/')} -

- )} -
- ) : ( -

- {t('projects.fetchingProjects')} -

- )} -
- ) : projects.length === 0 ? ( -
-
- -
-

{t('projects.noProjects')}

-

- {t('projects.runClaudeCli')} -

-
- ) : filteredProjects.length === 0 ? ( -
-
- -
-

{t('projects.noMatchingProjects')}

-

- {t('projects.tryDifferentSearch')} -

-
- ) : ( - filteredProjects.map((project) => { - const isExpanded = expandedProjects.has(project.name); - const isSelected = selectedProject?.name === project.name; - const isStarred = isProjectStarred(project.name); - const isDeleting = deletingProjects.has(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={t('projects.projectNamePlaceholder')} - 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 */} - - - -
- {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] ? ( -
-

{t('sessions.noSessions')}

-
- ) : ( - getAllSessions(project).map((session) => { - // Handle Claude, Cursor, and Codex session formats - const isCursorSession = session.__provider === 'cursor'; - const isCodexSession = session.__provider === 'codex'; - - // Calculate if session is active (within last 10 minutes) - const getSessionDate = () => { - if (isCursorSession) return new Date(session.createdAt); - if (isCodexSession) return new Date(session.createdAt || session.lastActivity); - return new Date(session.lastActivity); - }; - const sessionDate = getSessionDate(); - const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60)); - const isActive = diffInMinutes < 10; - - // Get session display values - const getSessionName = () => { - if (isCursorSession) return session.name || t('projects.untitledSession'); - if (isCodexSession) return session.summary || session.name || t('projects.codexSession'); - return session.summary || t('projects.newSession'); - }; - const sessionName = getSessionName(); - const getSessionTime = () => { - if (isCursorSession) return session.createdAt; - if (isCodexSession) return session.createdAt || session.lastActivity; - return session.lastActivity; - }; - const sessionTime = getSessionTime(); - 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 ? ( - - ) : isCodexSession ? ( - - ) : ( - - )} -
-
-
- {sessionName} -
-
- - - {formatTimeAgo(sessionTime, currentTime, t)} - - {messageCount > 0 && ( - - {messageCount} - - )} - {/* Provider tiny icon */} - - {isCursorSession ? ( - - ) : isCodexSession ? ( - - ) : ( - - )} - -
-
- {!isCursorSession && ( - - )} -
-
-
- - {/* Desktop Session Item */} -
- - {!isCursorSession && ( -
- {editingSession === session.id && !isCodexSession ? ( - <> - 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 - /> - - - - ) : ( - <> - {!isCodexSession && ( - - )} - - - )} -
- )} -
-
- ); - }) - )} - - {/* Show More Sessions Button */} - {getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && ( - - )} - - {/* Sessions - New Session Button */} -
- -
- - -
- )} -
- ); - }) - )} -
- - - {/* // ! TODO: Move this to a new VersionUpdateNotification component */} - {/* Version Update Notification */} - {updateAvailable && ( -
- {/* Desktop Version Notification */} -
- -
- - {/* Mobile Version Notification */} -
- -
-
- )} - - {/* Settings Section */} -
- {/* Mobile Settings */} -
- -
- - {/* Desktop Settings */} - -
-
- - )} - - setShowVersionModal(false)} - releaseInfo={releaseInfo} - currentVersion={currentVersion} - latestVersion={latestVersion} - /> - - ); -} - -export default Sidebar; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..9e47e5b --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,241 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import VersionUpgradeModal from './modals/VersionUpgradeModal'; +import { useDeviceSettings } from '../hooks/useDeviceSettings'; +import { useVersionCheck } from '../hooks/useVersionCheck'; +import { useUiPreferences } from '../hooks/useUiPreferences'; +import { useSidebarController } from '../hooks/useSidebarController'; +import { useTaskMaster } from '../contexts/TaskMasterContext'; +import { useTasksSettings } from '../contexts/TasksSettingsContext'; +import SidebarCollapsed from './sidebar/SidebarCollapsed'; +import SidebarContent from './sidebar/SidebarContent'; +import SidebarModals from './sidebar/SidebarModals'; +import type { Project } from '../types/app'; +import type { SidebarProjectListProps } from './sidebar/SidebarProjectList'; +import type { MCPServerStatus, SidebarProps } from './sidebar/types'; + +type TaskMasterSidebarContext = { + setCurrentProject: (project: Project) => void; + mcpServerStatus: MCPServerStatus; +}; + +function Sidebar({ + projects, + selectedProject, + selectedSession, + onProjectSelect, + onSessionSelect, + onNewSession, + onSessionDelete, + onProjectDelete, + isLoading, + loadingProgress, + onRefresh, + onShowSettings, + isMobile, +}: SidebarProps) { + const { t } = useTranslation(['sidebar', 'common']); + const { isPWA } = useDeviceSettings({ trackMobile: false }); + const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck( + 'siteboon', + 'claudecodeui', + ); + const { preferences, setPreference } = useUiPreferences(); + const { sidebarVisible } = preferences; + const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext; + const { tasksEnabled } = useTasksSettings(); + + const { + isSidebarCollapsed, + expandedProjects, + editingProject, + showNewProject, + editingName, + loadingSessions, + initialSessionsLoaded, + currentTime, + isRefreshing, + editingSession, + editingSessionName, + searchFilter, + deletingProjects, + deleteConfirmation, + sessionDeleteConfirmation, + showVersionModal, + filteredProjects, + handleTouchClick, + toggleProject, + handleSessionClick, + toggleStarProject, + isProjectStarred, + getProjectSessions, + startEditing, + cancelEditing, + saveProjectName, + showDeleteSessionConfirmation, + confirmDeleteSession, + requestProjectDelete, + confirmDeleteProject, + loadMoreSessions, + handleProjectSelect, + refreshProjects, + updateSessionSummary, + collapseSidebar: handleCollapseSidebar, + expandSidebar: handleExpandSidebar, + setShowNewProject, + setEditingName, + setEditingSession, + setEditingSessionName, + setSearchFilter, + setDeleteConfirmation, + setSessionDeleteConfirmation, + setShowVersionModal, + } = useSidebarController({ + projects, + selectedProject, + selectedSession, + isLoading, + isMobile, + t, + onRefresh, + onProjectSelect, + onSessionSelect, + onSessionDelete, + onProjectDelete, + setCurrentProject, + setSidebarVisible: (visible) => setPreference('sidebarVisible', visible), + sidebarVisible, + }); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + document.documentElement.classList.toggle('pwa-mode', isPWA); + document.body.classList.toggle('pwa-mode', isPWA); + }, [isPWA]); + + const handleProjectCreated = () => { + if (window.refreshProjects) { + void window.refreshProjects(); + return; + } + + window.location.reload(); + }; + + const projectListProps: SidebarProjectListProps = { + projects, + filteredProjects, + selectedProject, + selectedSession, + isLoading, + loadingProgress, + expandedProjects, + editingProject, + editingName, + loadingSessions, + initialSessionsLoaded, + currentTime, + editingSession, + editingSessionName, + deletingProjects, + tasksEnabled, + mcpServerStatus, + getProjectSessions, + isProjectStarred, + onEditingNameChange: setEditingName, + onToggleProject: toggleProject, + onProjectSelect: handleProjectSelect, + onToggleStarProject: toggleStarProject, + onStartEditingProject: startEditing, + onCancelEditingProject: cancelEditing, + onSaveProjectName: (projectName) => { + void saveProjectName(projectName); + }, + onDeleteProject: requestProjectDelete, + onSessionSelect: handleSessionClick, + onDeleteSession: showDeleteSessionConfirmation, + onLoadMoreSessions: (project) => { + void loadMoreSessions(project); + }, + onNewSession, + onEditingSessionNameChange: setEditingSessionName, + onStartEditingSession: (sessionId, initialName) => { + setEditingSession(sessionId); + setEditingSessionName(initialName); + }, + onCancelEditingSession: () => { + setEditingSession(null); + setEditingSessionName(''); + }, + onSaveEditingSession: (projectName, sessionId, summary) => { + void updateSessionSummary(projectName, sessionId, summary); + }, + touchHandlerFactory: handleTouchClick, + t, + }; + + return ( + <> + {isSidebarCollapsed ? ( + setShowVersionModal(true)} + t={t} + /> + ) : ( + <> + setShowNewProject(false)} + onProjectCreated={handleProjectCreated} + deleteConfirmation={deleteConfirmation} + onCancelDeleteProject={() => setDeleteConfirmation(null)} + onConfirmDeleteProject={confirmDeleteProject} + sessionDeleteConfirmation={sessionDeleteConfirmation} + onCancelDeleteSession={() => setSessionDeleteConfirmation(null)} + onConfirmDeleteSession={confirmDeleteSession} + t={t} + /> + + setSearchFilter('')} + onRefresh={() => { + void refreshProjects(); + }} + isRefreshing={isRefreshing} + onCreateProject={() => setShowNewProject(true)} + onCollapseSidebar={handleCollapseSidebar} + updateAvailable={updateAvailable} + releaseInfo={releaseInfo} + latestVersion={latestVersion} + onShowVersionModal={() => setShowVersionModal(true)} + onShowSettings={onShowSettings} + projectListProps={projectListProps} + t={t} + /> + + )} + + setShowVersionModal(false)} + releaseInfo={releaseInfo} + currentVersion={currentVersion} + latestVersion={latestVersion} + /> + + ); +} + +export default Sidebar; diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx new file mode 100644 index 0000000..52749ed --- /dev/null +++ b/src/components/app/AppContent.tsx @@ -0,0 +1,154 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import Sidebar from '../Sidebar'; +import MainContent from '../MainContent'; +import MobileNav from '../MobileNav'; +import Settings from '../Settings'; + +import { useWebSocket } from '../../contexts/WebSocketContext'; +import { useDeviceSettings } from '../../hooks/useDeviceSettings'; +import { useSessionProtection } from '../../hooks/useSessionProtection'; +import { useProjectsState } from '../../hooks/useProjectsState'; + +export default function AppContent() { + const navigate = useNavigate(); + const { sessionId } = useParams<{ sessionId?: string }>(); + const { t } = useTranslation('common'); + const { isMobile } = useDeviceSettings({ trackPWA: false }); + const { ws, sendMessage, latestMessage } = useWebSocket(); + + const { + activeSessions, + processingSessions, + markSessionAsActive, + markSessionAsInactive, + markSessionAsProcessing, + markSessionAsNotProcessing, + replaceTemporarySession, + } = useSessionProtection(); + + const { + projects, + selectedProject, + selectedSession, + activeTab, + sidebarOpen, + isLoadingProjects, + isInputFocused, + showSettings, + settingsInitialTab, + externalMessageUpdate, + setActiveTab, + setSidebarOpen, + setIsInputFocused, + setShowSettings, + openSettings, + fetchProjects, + sidebarSharedProps, + } = useProjectsState({ + sessionId, + navigate, + latestMessage, + isMobile, + activeSessions, + }); + + useEffect(() => { + window.refreshProjects = fetchProjects; + + return () => { + if (window.refreshProjects === fetchProjects) { + delete window.refreshProjects; + } + }; + }, [fetchProjects]); + + useEffect(() => { + window.openSettings = openSettings; + + return () => { + if (window.openSettings === openSettings) { + delete window.openSettings; + } + }; + }, [openSettings]); + + return ( +
+ {!isMobile ? ( +
+ +
+ ) : ( +
+
+ )} + +
+ setSidebarOpen(true)} + isLoading={isLoadingProjects} + onInputFocusChange={setIsInputFocused} + onSessionActive={markSessionAsActive} + onSessionInactive={markSessionAsInactive} + onSessionProcessing={markSessionAsProcessing} + onSessionNotProcessing={markSessionAsNotProcessing} + processingSessions={processingSessions} + onReplaceTemporarySession={replaceTemporarySession} + onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)} + onShowSettings={() => setShowSettings(true)} + externalMessageUpdate={externalMessageUpdate} + /> +
+ + {isMobile && ( + + )} + + setShowSettings(false)} + projects={projects as any} + initialTab={settingsInitialTab} + /> +
+ ); +} diff --git a/src/components/sidebar/SessionProviderIcon.tsx b/src/components/sidebar/SessionProviderIcon.tsx new file mode 100644 index 0000000..5083a25 --- /dev/null +++ b/src/components/sidebar/SessionProviderIcon.tsx @@ -0,0 +1,21 @@ +import type { SessionProvider } from '../../types/app'; +import ClaudeLogo from '../ClaudeLogo'; +import CodexLogo from '../CodexLogo'; +import CursorLogo from '../CursorLogo'; + +type SessionProviderIconProps = { + provider: SessionProvider; + className: string; +}; + +export default function SessionProviderIcon({ provider, className }: SessionProviderIconProps) { + if (provider === 'cursor') { + return ; + } + + if (provider === 'codex') { + return ; + } + + return ; +} diff --git a/src/components/sidebar/SidebarCollapsed.tsx b/src/components/sidebar/SidebarCollapsed.tsx new file mode 100644 index 0000000..a98756c --- /dev/null +++ b/src/components/sidebar/SidebarCollapsed.tsx @@ -0,0 +1,59 @@ +import { Settings, Sparkles } from 'lucide-react'; +import type { TFunction } from 'i18next'; + +type SidebarCollapsedProps = { + onExpand: () => void; + onShowSettings: () => void; + updateAvailable: boolean; + onShowVersionModal: () => void; + t: TFunction; +}; + +export default function SidebarCollapsed({ + onExpand, + onShowSettings, + updateAvailable, + onShowVersionModal, + t, +}: SidebarCollapsedProps) { + return ( +
+ + + + + {updateAvailable && ( + + )} +
+ ); +} diff --git a/src/components/sidebar/SidebarContent.tsx b/src/components/sidebar/SidebarContent.tsx new file mode 100644 index 0000000..5ec7e62 --- /dev/null +++ b/src/components/sidebar/SidebarContent.tsx @@ -0,0 +1,84 @@ +import { ScrollArea } from '../ui/scroll-area'; +import type { TFunction } from 'i18next'; +import type { Project } from '../../types/app'; +import type { ReleaseInfo } from '../../types/sharedTypes'; +import SidebarFooter from './SidebarFooter'; +import SidebarHeader from './SidebarHeader'; +import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; + +type SidebarContentProps = { + isPWA: boolean; + isMobile: boolean; + isLoading: boolean; + projects: Project[]; + searchFilter: string; + onSearchFilterChange: (value: string) => void; + onClearSearchFilter: () => void; + onRefresh: () => void; + isRefreshing: boolean; + onCreateProject: () => void; + onCollapseSidebar: () => void; + updateAvailable: boolean; + releaseInfo: ReleaseInfo | null; + latestVersion: string | null; + onShowVersionModal: () => void; + onShowSettings: () => void; + projectListProps: SidebarProjectListProps; + t: TFunction; +}; + +export default function SidebarContent({ + isPWA, + isMobile, + isLoading, + projects, + searchFilter, + onSearchFilterChange, + onClearSearchFilter, + onRefresh, + isRefreshing, + onCreateProject, + onCollapseSidebar, + updateAvailable, + releaseInfo, + latestVersion, + onShowVersionModal, + onShowSettings, + projectListProps, + t, +}: SidebarContentProps) { + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/components/sidebar/SidebarFooter.tsx b/src/components/sidebar/SidebarFooter.tsx new file mode 100644 index 0000000..48fb41e --- /dev/null +++ b/src/components/sidebar/SidebarFooter.tsx @@ -0,0 +1,94 @@ +import { Settings } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import type { ReleaseInfo } from '../../types/sharedTypes'; +import { Button } from '../ui/button'; + +type SidebarFooterProps = { + updateAvailable: boolean; + releaseInfo: ReleaseInfo | null; + latestVersion: string | null; + onShowVersionModal: () => void; + onShowSettings: () => void; + t: TFunction; +}; + +export default function SidebarFooter({ + updateAvailable, + releaseInfo, + latestVersion, + onShowVersionModal, + onShowSettings, + t, +}: SidebarFooterProps) { + return ( + <> + {updateAvailable && ( +
+
+ +
+ +
+ +
+
+ )} + +
+
+ +
+ + +
+ + ); +} diff --git a/src/components/sidebar/SidebarHeader.tsx b/src/components/sidebar/SidebarHeader.tsx new file mode 100644 index 0000000..2866fe9 --- /dev/null +++ b/src/components/sidebar/SidebarHeader.tsx @@ -0,0 +1,187 @@ +import { FolderPlus, MessageSquare, RefreshCw, Search, X } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { IS_PLATFORM } from '../../constants/config'; + +type SidebarHeaderProps = { + isPWA: boolean; + isMobile: boolean; + isLoading: boolean; + projectsCount: number; + searchFilter: string; + onSearchFilterChange: (value: string) => void; + onClearSearchFilter: () => void; + onRefresh: () => void; + isRefreshing: boolean; + onCreateProject: () => void; + onCollapseSidebar: () => void; + t: TFunction; +}; + +export default function SidebarHeader({ + isPWA, + isMobile, + isLoading, + projectsCount, + searchFilter, + onSearchFilterChange, + onClearSearchFilter, + onRefresh, + isRefreshing, + onCreateProject, + onCollapseSidebar, + t, +}: SidebarHeaderProps) { + return ( + <> +
+
+ {IS_PLATFORM ? ( + +
+ +
+
+

{t('app.title')}

+

{t('app.subtitle')}

+
+
+ ) : ( +
+
+ +
+
+

{t('app.title')}

+

{t('app.subtitle')}

+
+
+ )} + + +
+ +
+
+ {IS_PLATFORM ? ( + +
+ +
+
+

{t('app.title')}

+

{t('projects.title')}

+
+
+ ) : ( +
+
+ +
+
+

{t('app.title')}

+

{t('projects.title')}

+
+
+ )} + +
+ + +
+
+
+
+ + {!isLoading && !isMobile && ( +
+
+ + +
+
+ )} + + {projectsCount > 0 && !isLoading && ( +
+
+ + onSearchFilterChange(event.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 && ( + + )} +
+
+ )} + + ); +} diff --git a/src/components/sidebar/SidebarModals.tsx b/src/components/sidebar/SidebarModals.tsx new file mode 100644 index 0000000..bee8baf --- /dev/null +++ b/src/components/sidebar/SidebarModals.tsx @@ -0,0 +1,143 @@ +import ReactDOM from 'react-dom'; +import { AlertTriangle, Trash2 } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { Button } from '../ui/button'; +import ProjectCreationWizard from '../ProjectCreationWizard'; +import type { DeleteProjectConfirmation, SessionDeleteConfirmation } from './types'; + +type SidebarModalsProps = { + showNewProject: boolean; + onCloseNewProject: () => void; + onProjectCreated: () => void; + deleteConfirmation: DeleteProjectConfirmation | null; + onCancelDeleteProject: () => void; + onConfirmDeleteProject: () => void; + sessionDeleteConfirmation: SessionDeleteConfirmation | null; + onCancelDeleteSession: () => void; + onConfirmDeleteSession: () => void; + t: TFunction; +}; + +export default function SidebarModals({ + showNewProject, + onCloseNewProject, + onProjectCreated, + deleteConfirmation, + onCancelDeleteProject, + onConfirmDeleteProject, + sessionDeleteConfirmation, + onCancelDeleteSession, + onConfirmDeleteSession, + t, +}: SidebarModalsProps) { + return ( + <> + {showNewProject && + ReactDOM.createPortal( + , + document.body, + )} + + {deleteConfirmation && + ReactDOM.createPortal( +
+
+
+
+
+ +
+
+

+ {t('deleteConfirmation.deleteProject')} +

+

+ {t('deleteConfirmation.confirmDelete')}{' '} + + {deleteConfirmation.project.displayName || deleteConfirmation.project.name} + + ? +

+ {deleteConfirmation.sessionCount > 0 && ( +
+

+ {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })} +

+

+ {t('deleteConfirmation.allConversationsDeleted')} +

+
+ )} +

+ {t('deleteConfirmation.cannotUndo')} +

+
+
+
+
+ + +
+
+
, + document.body, + )} + + {sessionDeleteConfirmation && + ReactDOM.createPortal( +
+
+
+
+
+ +
+
+

+ {t('deleteConfirmation.deleteSession')} +

+

+ {t('deleteConfirmation.confirmDelete')}{' '} + + {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')} + + ? +

+

+ {t('deleteConfirmation.cannotUndo')} +

+
+
+
+
+ + +
+
+
, + document.body, + )} + + ); +} diff --git a/src/components/sidebar/SidebarProjectItem.tsx b/src/components/sidebar/SidebarProjectItem.tsx new file mode 100644 index 0000000..949e9c0 --- /dev/null +++ b/src/components/sidebar/SidebarProjectItem.tsx @@ -0,0 +1,437 @@ +import { Button } from '../ui/button'; +import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { cn } from '../../lib/utils'; +import TaskIndicator from '../TaskIndicator'; +import type { Project, ProjectSession, SessionProvider } from '../../types/app'; +import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from './types'; +import { getTaskIndicatorStatus } from './utils'; +import SidebarProjectSessions from './SidebarProjectSessions'; + +type SidebarProjectItemProps = { + project: Project; + selectedProject: Project | null; + selectedSession: ProjectSession | null; + isExpanded: boolean; + isDeleting: boolean; + isStarred: boolean; + editingProject: string | null; + editingName: string; + sessions: SessionWithProvider[]; + initialSessionsLoaded: boolean; + isLoadingSessions: boolean; + currentTime: Date; + editingSession: string | null; + editingSessionName: string; + tasksEnabled: boolean; + mcpServerStatus: MCPServerStatus; + onEditingNameChange: (name: string) => void; + onToggleProject: (projectName: string) => void; + onProjectSelect: (project: Project) => void; + onToggleStarProject: (projectName: string) => void; + onStartEditingProject: (project: Project) => void; + onCancelEditingProject: () => void; + onSaveProjectName: (projectName: string) => void; + onDeleteProject: (project: Project) => void; + onSessionSelect: (session: SessionWithProvider, projectName: string) => void; + onDeleteSession: ( + projectName: string, + sessionId: string, + sessionTitle: string, + provider: SessionProvider, + ) => void; + onLoadMoreSessions: (project: Project) => void; + onNewSession: (project: Project) => void; + onEditingSessionNameChange: (value: string) => void; + onStartEditingSession: (sessionId: string, initialName: string) => void; + onCancelEditingSession: () => void; + onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void; + touchHandlerFactory: TouchHandlerFactory; + t: TFunction; +}; + +const getSessionCountDisplay = (sessions: SessionWithProvider[], hasMoreSessions: boolean): string => { + const sessionCount = sessions.length; + if (hasMoreSessions && sessionCount >= 5) { + return `${sessionCount}+`; + } + + return `${sessionCount}`; +}; + +export default function SidebarProjectItem({ + project, + selectedProject, + selectedSession, + isExpanded, + isDeleting, + isStarred, + editingProject, + editingName, + sessions, + initialSessionsLoaded, + isLoadingSessions, + currentTime, + editingSession, + editingSessionName, + tasksEnabled, + mcpServerStatus, + onEditingNameChange, + onToggleProject, + onProjectSelect, + onToggleStarProject, + onStartEditingProject, + onCancelEditingProject, + onSaveProjectName, + onDeleteProject, + onSessionSelect, + onDeleteSession, + onLoadMoreSessions, + onNewSession, + onEditingSessionNameChange, + onStartEditingSession, + onCancelEditingSession, + onSaveEditingSession, + touchHandlerFactory, + t, +}: SidebarProjectItemProps) { + const isSelected = selectedProject?.name === project.name; + const isEditing = editingProject === project.name; + const hasMoreSessions = project.sessionMeta?.hasMore !== false; + const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions); + const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`; + const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus); + + const toggleProject = () => onToggleProject(project.name); + const toggleStarProject = () => onToggleStarProject(project.name); + + const saveProjectName = () => { + onSaveProjectName(project.name); + }; + + const selectAndToggleProject = () => { + if (selectedProject?.name !== project.name) { + onProjectSelect(project); + } + + toggleProject(); + }; + + return ( +
+
+
+
+
+
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ +
+ {isEditing ? ( + onEditingNameChange(event.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={t('projects.projectNamePlaceholder')} + autoFocus + autoComplete="off" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + saveProjectName(); + } + + if (event.key === 'Escape') { + onCancelEditingProject(); + } + }} + style={{ + fontSize: '16px', + WebkitAppearance: 'none', + borderRadius: '8px', + }} + /> + ) : ( + <> +
+

{project.displayName}

+ {tasksEnabled && ( + + )} +
+

{sessionCountLabel}

+ + )} +
+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + + + +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + )} +
+
+
+
+ + +
+ + +
+ ); +} diff --git a/src/components/sidebar/SidebarProjectList.tsx b/src/components/sidebar/SidebarProjectList.tsx new file mode 100644 index 0000000..0718bfe --- /dev/null +++ b/src/components/sidebar/SidebarProjectList.tsx @@ -0,0 +1,153 @@ +import type { TFunction } from 'i18next'; +import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../types/app'; +import type { + LoadingSessionsByProject, + MCPServerStatus, + SessionWithProvider, + TouchHandlerFactory, +} from './types'; +import SidebarProjectItem from './SidebarProjectItem'; +import SidebarProjectsState from './SidebarProjectsState'; + +export type SidebarProjectListProps = { + projects: Project[]; + filteredProjects: Project[]; + selectedProject: Project | null; + selectedSession: ProjectSession | null; + isLoading: boolean; + loadingProgress: LoadingProgress | null; + expandedProjects: Set; + editingProject: string | null; + editingName: string; + loadingSessions: LoadingSessionsByProject; + initialSessionsLoaded: Set; + currentTime: Date; + editingSession: string | null; + editingSessionName: string; + deletingProjects: Set; + tasksEnabled: boolean; + mcpServerStatus: MCPServerStatus; + getProjectSessions: (project: Project) => SessionWithProvider[]; + isProjectStarred: (projectName: string) => boolean; + onEditingNameChange: (value: string) => void; + onToggleProject: (projectName: string) => void; + onProjectSelect: (project: Project) => void; + onToggleStarProject: (projectName: string) => void; + onStartEditingProject: (project: Project) => void; + onCancelEditingProject: () => void; + onSaveProjectName: (projectName: string) => void; + onDeleteProject: (project: Project) => void; + onSessionSelect: (session: SessionWithProvider, projectName: string) => void; + onDeleteSession: ( + projectName: string, + sessionId: string, + sessionTitle: string, + provider: SessionProvider, + ) => void; + onLoadMoreSessions: (project: Project) => void; + onNewSession: (project: Project) => void; + onEditingSessionNameChange: (value: string) => void; + onStartEditingSession: (sessionId: string, initialName: string) => void; + onCancelEditingSession: () => void; + onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void; + touchHandlerFactory: TouchHandlerFactory; + t: TFunction; +}; + +export default function SidebarProjectList({ + projects, + filteredProjects, + selectedProject, + selectedSession, + isLoading, + loadingProgress, + expandedProjects, + editingProject, + editingName, + loadingSessions, + initialSessionsLoaded, + currentTime, + editingSession, + editingSessionName, + deletingProjects, + tasksEnabled, + mcpServerStatus, + getProjectSessions, + isProjectStarred, + onEditingNameChange, + onToggleProject, + onProjectSelect, + onToggleStarProject, + onStartEditingProject, + onCancelEditingProject, + onSaveProjectName, + onDeleteProject, + onSessionSelect, + onDeleteSession, + onLoadMoreSessions, + onNewSession, + onEditingSessionNameChange, + onStartEditingSession, + onCancelEditingSession, + onSaveEditingSession, + touchHandlerFactory, + t, +}: SidebarProjectListProps) { + const state = ( + + ); + + const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0; + + return ( +
+ {!showProjects + ? state + : filteredProjects.map((project) => ( + + ))} +
+ ); +} diff --git a/src/components/sidebar/SidebarProjectSessions.tsx b/src/components/sidebar/SidebarProjectSessions.tsx new file mode 100644 index 0000000..b504bef --- /dev/null +++ b/src/components/sidebar/SidebarProjectSessions.tsx @@ -0,0 +1,160 @@ +import { ChevronDown, Plus } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { Button } from '../ui/button'; +import type { Project, ProjectSession, SessionProvider } from '../../types/app'; +import type { SessionWithProvider, TouchHandlerFactory } from './types'; +import SidebarSessionItem from './SidebarSessionItem'; + +type SidebarProjectSessionsProps = { + project: Project; + isExpanded: boolean; + sessions: SessionWithProvider[]; + selectedSession: ProjectSession | null; + initialSessionsLoaded: boolean; + isLoadingSessions: boolean; + currentTime: Date; + editingSession: string | null; + editingSessionName: string; + onEditingSessionNameChange: (value: string) => void; + onStartEditingSession: (sessionId: string, initialName: string) => void; + onCancelEditingSession: () => void; + onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void; + onProjectSelect: (project: Project) => void; + onSessionSelect: (session: SessionWithProvider, projectName: string) => void; + onDeleteSession: ( + projectName: string, + sessionId: string, + sessionTitle: string, + provider: SessionProvider, + ) => void; + onLoadMoreSessions: (project: Project) => void; + onNewSession: (project: Project) => void; + touchHandlerFactory: TouchHandlerFactory; + t: TFunction; +}; + +function SessionListSkeleton() { + return ( + <> + {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} + + ); +} + +export default function SidebarProjectSessions({ + project, + isExpanded, + sessions, + selectedSession, + initialSessionsLoaded, + isLoadingSessions, + currentTime, + editingSession, + editingSessionName, + onEditingSessionNameChange, + onStartEditingSession, + onCancelEditingSession, + onSaveEditingSession, + onProjectSelect, + onSessionSelect, + onDeleteSession, + onLoadMoreSessions, + onNewSession, + touchHandlerFactory, + t, +}: SidebarProjectSessionsProps) { + if (!isExpanded) { + return null; + } + + const hasSessions = sessions.length > 0; + const hasMoreSessions = project.sessionMeta?.hasMore !== false; + + return ( +
+ {!initialSessionsLoaded ? ( + + ) : !hasSessions && !isLoadingSessions ? ( +
+

{t('sessions.noSessions')}

+
+ ) : ( + sessions.map((session) => ( + + )) + )} + + {hasSessions && hasMoreSessions && ( + + )} + +
+ +
+ + +
+ ); +} diff --git a/src/components/sidebar/SidebarProjectsState.tsx b/src/components/sidebar/SidebarProjectsState.tsx new file mode 100644 index 0000000..8f51674 --- /dev/null +++ b/src/components/sidebar/SidebarProjectsState.tsx @@ -0,0 +1,81 @@ +import { Folder, Search } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import type { LoadingProgress } from '../../types/app'; + +type SidebarProjectsStateProps = { + isLoading: boolean; + loadingProgress: LoadingProgress | null; + projectsCount: number; + filteredProjectsCount: number; + t: TFunction; +}; + +export default function SidebarProjectsState({ + isLoading, + loadingProgress, + projectsCount, + filteredProjectsCount, + t, +}: SidebarProjectsStateProps) { + if (isLoading) { + return ( +
+
+
+
+

{t('projects.loadingProjects')}

+

{t('projects.fetchingProjects')}

+

{t('projects.loadingProjects')}

+ {loadingProgress && loadingProgress.total > 0 ? ( +
+
+
+
+

+ {loadingProgress.current}/{loadingProgress.total} {t('projects.projects')} +

+ {loadingProgress.currentProject && ( +

+ {loadingProgress.currentProject.split('-').slice(-2).join('/')} +

+ )} +
+ ) : ( +

{t('projects.fetchingProjects')}

+ )} +
+ ); + } + + if (projectsCount === 0) { + return ( +
+
+ +
+

{t('projects.noProjects')}

+

{t('projects.runClaudeCli')}

+
+ ); + } + + if (filteredProjectsCount === 0) { + return ( +
+
+ +
+

{t('projects.noMatchingProjects')}

+

{t('projects.tryDifferentSearch')}

+
+ ); + } + + return null; +} diff --git a/src/components/sidebar/SidebarSessionItem.tsx b/src/components/sidebar/SidebarSessionItem.tsx new file mode 100644 index 0000000..d46bf72 --- /dev/null +++ b/src/components/sidebar/SidebarSessionItem.tsx @@ -0,0 +1,239 @@ +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { Check, Clock, Edit2, Trash2, X } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { cn } from '../../lib/utils'; +import { formatTimeAgo } from '../../utils/dateUtils'; +import type { Project, ProjectSession, SessionProvider } from '../../types/app'; +import type { SessionWithProvider, TouchHandlerFactory } from './types'; +import { createSessionViewModel } from './utils'; +import SessionProviderIcon from './SessionProviderIcon'; + +type SidebarSessionItemProps = { + project: Project; + session: SessionWithProvider; + selectedSession: ProjectSession | null; + currentTime: Date; + editingSession: string | null; + editingSessionName: string; + onEditingSessionNameChange: (value: string) => void; + onStartEditingSession: (sessionId: string, initialName: string) => void; + onCancelEditingSession: () => void; + onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void; + onProjectSelect: (project: Project) => void; + onSessionSelect: (session: SessionWithProvider, projectName: string) => void; + onDeleteSession: ( + projectName: string, + sessionId: string, + sessionTitle: string, + provider: SessionProvider, + ) => void; + touchHandlerFactory: TouchHandlerFactory; + t: TFunction; +}; + +export default function SidebarSessionItem({ + project, + session, + selectedSession, + currentTime, + editingSession, + editingSessionName, + onEditingSessionNameChange, + onStartEditingSession, + onCancelEditingSession, + onSaveEditingSession, + onProjectSelect, + onSessionSelect, + onDeleteSession, + touchHandlerFactory, + t, +}: SidebarSessionItemProps) { + const sessionView = createSessionViewModel(session, currentTime, t); + const isSelected = selectedSession?.id === session.id; + + const selectMobileSession = () => { + onProjectSelect(project); + onSessionSelect(session, project.name); + }; + + const saveEditedSession = () => { + onSaveEditingSession(project.name, session.id, editingSessionName); + }; + + const requestDeleteSession = () => { + onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider); + }; + + return ( +
+ {sessionView.isActive && ( +
+
+
+ )} + +
+
+
+
+ +
+ +
+
{sessionView.sessionName}
+
+ + + {formatTimeAgo(sessionView.sessionTime, currentTime, t)} + + {sessionView.messageCount > 0 && ( + + {sessionView.messageCount} + + )} + + + +
+
+ + {!sessionView.isCursorSession && ( + + )} +
+
+
+ +
+ + + {!sessionView.isCursorSession && ( +
+ {editingSession === session.id && !sessionView.isCodexSession ? ( + <> + onEditingSessionNameChange(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === 'Enter') { + saveEditedSession(); + } else if (event.key === 'Escape') { + onCancelEditingSession(); + } + }} + onClick={(event) => event.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 + /> + + + + ) : ( + <> + {!sessionView.isCodexSession && ( + + )} + + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/sidebar/types.ts b/src/components/sidebar/types.ts new file mode 100644 index 0000000..6422ef1 --- /dev/null +++ b/src/components/sidebar/types.ts @@ -0,0 +1,57 @@ +import type React from 'react'; +import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../types/app'; + +export type ProjectSortOrder = 'name' | 'date'; + +export type SessionWithProvider = ProjectSession & { + __provider: SessionProvider; +}; + +export type AdditionalSessionsByProject = Record; +export type LoadingSessionsByProject = Record; + +export type DeleteProjectConfirmation = { + project: Project; + sessionCount: number; +}; + +export type SessionDeleteConfirmation = { + projectName: string; + sessionId: string; + sessionTitle: string; + provider: SessionProvider; +}; + +export type SidebarProps = { + projects: Project[]; + selectedProject: Project | null; + selectedSession: ProjectSession | null; + onProjectSelect: (project: Project) => void; + onSessionSelect: (session: ProjectSession) => void; + onNewSession: (project: Project) => void; + onSessionDelete?: (sessionId: string) => void; + onProjectDelete?: (projectName: string) => void; + isLoading: boolean; + loadingProgress: LoadingProgress | null; + onRefresh: () => Promise | void; + onShowSettings: () => void; + isMobile: boolean; +}; + +export type SessionViewModel = { + isCursorSession: boolean; + isCodexSession: boolean; + isActive: boolean; + sessionName: string; + sessionTime: string; + messageCount: number; +}; + +export type MCPServerStatus = { + hasMCPServer?: boolean; + isConfigured?: boolean; +} | null; + +export type TouchHandlerFactory = ( + callback: () => void, +) => (event: React.TouchEvent) => void; diff --git a/src/components/sidebar/utils.ts b/src/components/sidebar/utils.ts new file mode 100644 index 0000000..1c8621b --- /dev/null +++ b/src/components/sidebar/utils.ts @@ -0,0 +1,200 @@ +import type { TFunction } from 'i18next'; +import type { Project } from '../../types/app'; +import type { + AdditionalSessionsByProject, + ProjectSortOrder, + SessionViewModel, + SessionWithProvider, +} from './types'; + +export const readProjectSortOrder = (): ProjectSortOrder => { + try { + const rawSettings = localStorage.getItem('claude-settings'); + if (!rawSettings) { + return 'name'; + } + + const settings = JSON.parse(rawSettings) as { projectSortOrder?: ProjectSortOrder }; + return settings.projectSortOrder === 'date' ? 'date' : 'name'; + } catch { + return 'name'; + } +}; + +export const loadStarredProjects = (): Set => { + try { + const saved = localStorage.getItem('starredProjects'); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch { + return new Set(); + } +}; + +export const persistStarredProjects = (starredProjects: Set) => { + try { + localStorage.setItem('starredProjects', JSON.stringify([...starredProjects])); + } catch { + // Keep UI responsive even if storage fails. + } +}; + +export const getSessionDate = (session: SessionWithProvider): Date => { + if (session.__provider === 'cursor') { + return new Date(session.createdAt || 0); + } + + if (session.__provider === 'codex') { + return new Date(session.createdAt || session.lastActivity || 0); + } + + return new Date(session.lastActivity || 0); +}; + +export const getSessionName = (session: SessionWithProvider, t: TFunction): string => { + if (session.__provider === 'cursor') { + return session.name || t('projects.untitledSession'); + } + + if (session.__provider === 'codex') { + return session.summary || session.name || t('projects.codexSession'); + } + + return session.summary || t('projects.newSession'); +}; + +export const getSessionTime = (session: SessionWithProvider): string => { + if (session.__provider === 'cursor') { + return String(session.createdAt || ''); + } + + if (session.__provider === 'codex') { + return String(session.createdAt || session.lastActivity || ''); + } + + return String(session.lastActivity || ''); +}; + +export const createSessionViewModel = ( + session: SessionWithProvider, + currentTime: Date, + t: TFunction, +): SessionViewModel => { + const sessionDate = getSessionDate(session); + const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60)); + + return { + isCursorSession: session.__provider === 'cursor', + isCodexSession: session.__provider === 'codex', + isActive: diffInMinutes < 10, + sessionName: getSessionName(session, t), + sessionTime: getSessionTime(session), + messageCount: Number(session.messageCount || 0), + }; +}; + +export const getAllSessions = ( + project: Project, + additionalSessions: AdditionalSessionsByProject, +): SessionWithProvider[] => { + const claudeSessions = [ + ...(project.sessions || []), + ...(additionalSessions[project.name] || []), + ].map((session) => ({ ...session, __provider: 'claude' as const })); + + const cursorSessions = (project.cursorSessions || []).map((session) => ({ + ...session, + __provider: 'cursor' as const, + })); + + const codexSessions = (project.codexSessions || []).map((session) => ({ + ...session, + __provider: 'codex' as const, + })); + + return [...claudeSessions, ...cursorSessions, ...codexSessions].sort( + (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), + ); +}; + +export const getProjectLastActivity = ( + project: Project, + additionalSessions: AdditionalSessionsByProject, +): Date => { + const sessions = getAllSessions(project, additionalSessions); + if (sessions.length === 0) { + return new Date(0); + } + + return sessions.reduce((latest, session) => { + const sessionDate = getSessionDate(session); + return sessionDate > latest ? sessionDate : latest; + }, new Date(0)); +}; + +export const sortProjects = ( + projects: Project[], + projectSortOrder: ProjectSortOrder, + starredProjects: Set, + additionalSessions: AdditionalSessionsByProject, +): Project[] => { + const byName = [...projects]; + + byName.sort((projectA, projectB) => { + const aStarred = starredProjects.has(projectA.name); + const bStarred = starredProjects.has(projectB.name); + + if (aStarred && !bStarred) { + return -1; + } + + if (!aStarred && bStarred) { + return 1; + } + + if (projectSortOrder === 'date') { + return ( + getProjectLastActivity(projectB, additionalSessions).getTime() - + getProjectLastActivity(projectA, additionalSessions).getTime() + ); + } + + return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name); + }); + + return byName; +}; + +export const filterProjects = (projects: Project[], searchFilter: string): Project[] => { + const normalizedSearch = searchFilter.trim().toLowerCase(); + if (!normalizedSearch) { + return projects; + } + + return projects.filter((project) => { + const displayName = (project.displayName || project.name).toLowerCase(); + const projectName = project.name.toLowerCase(); + return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch); + }); +}; + +export const getTaskIndicatorStatus = ( + project: Project, + mcpServerStatus: { hasMCPServer?: boolean; isConfigured?: boolean } | null, +) => { + const projectConfigured = Boolean(project.taskmaster?.hasTaskmaster); + const mcpConfigured = Boolean(mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured); + + if (projectConfigured && mcpConfigured) { + return 'fully-configured'; + } + + if (projectConfigured) { + return 'taskmaster-only'; + } + + if (mcpConfigured) { + return 'mcp-only'; + } + + return 'not-configured'; +}; diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx deleted file mode 100644 index 7d6e362..0000000 --- a/src/components/ui/badge.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from "react" -import { cva } from "class-variance-authority" -import { cn } from "../../lib/utils" - -const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ className, variant, ...props }) { - return ( -
- ) -} - -export { Badge, badgeVariants } \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..d25e38f --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx deleted file mode 100644 index 996ac34..0000000 --- a/src/components/ui/button.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react" -import { cva } from "class-variance-authority" -import { cn } from "../../lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => { - return ( -