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 && (
-
-
{
- e.stopPropagation();
- setSidebarOpen(false);
- }}
- onTouchStart={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setSidebarOpen(false);
- }}
- aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
- />
- e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
-
-
-
- )}
-
- {/* 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 && (
-
setShowVersionModal(true)}
- className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
- aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')}
- title={t('common:versionUpdate.ariaLabels.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')}
-
-
-
-
-
- setDeleteConfirmation(null)}
- >
- {t('actions.cancel')}
-
-
-
- {t('actions.delete')}
-
-
-
-
,
- document.body
- )}
-
- {/* Session Delete Confirmation Modal */}
- {sessionDeleteConfirmation && ReactDOM.createPortal(
-
-
-
-
-
-
-
- {t('deleteConfirmation.deleteSession')}
-
-
- {t('deleteConfirmation.confirmDelete')}{' '}
-
- {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
- ?
-
-
- {t('deleteConfirmation.cannotUndo')}
-
-
-
-
-
- setSessionDeleteConfirmation(null)}
- >
- {t('actions.cancel')}
-
-
-
- {t('actions.delete')}
-
-
-
-
,
- document.body
- )}
-
-
- {/* Header */}
-
- {/* Desktop Header */}
-
-
- {/* Mobile Header */}
-
-
- {IS_PLATFORM ? (
-
-
-
-
-
-
{t('app.title')}
-
{t('projects.title')}
-
-
- ) : (
-
-
-
-
-
-
{t('app.title')}
-
{t('projects.title')}
-
-
- )}
-
- {
- setIsRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setIsRefreshing(false);
- }
- }}
- disabled={isRefreshing}
- >
-
-
- setShowNewProject(true)}
- >
-
-
-
-
-
-
-
- {/* Action Buttons - Desktop only - Always show when not loading */}
- {!isLoading && !isMobile && (
-
-
- setShowNewProject(true)}
- title={t('tooltips.createProject')}
- >
-
- {t('projects.newProject')}
-
- {
- setIsRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setIsRefreshing(false);
- }
- }}
- disabled={isRefreshing}
- title={t('tooltips.refresh')}
- >
-
-
-
-
- )}
-
- {/* 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 && (
- setSearchFilter('')}
- className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
- >
-
-
- )}
-
-
- )}
-
- {/* 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 ? (
- <>
-
{
- e.stopPropagation();
- saveProjectName(project.name);
- }}
- >
-
-
-
{
- e.stopPropagation();
- cancelEditing();
- }}
- >
-
-
- >
- ) : (
- <>
- {/* Star button */}
-
{
- e.stopPropagation();
- toggleStarProject(project.name);
- }}
- onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
- title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
- >
-
-
-
{
- e.stopPropagation();
- deleteProject(project);
- }}
- onTouchEnd={handleTouchClick(() => deleteProject(project))}
- >
-
-
-
{
- e.stopPropagation();
- startEditing(project);
- }}
- onTouchEnd={handleTouchClick(() => startEditing(project))}
- >
-
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
- >
- )}
-
-
-
-
-
- {/* Desktop Project Item */}
-
{
- // Desktop behavior: select project and toggle
- if (selectedProject?.name !== project.name) {
- handleProjectSelect(project);
- }
- toggleProject(project.name);
- }}
- onTouchEnd={handleTouchClick(() => {
- if (selectedProject?.name !== project.name) {
- handleProjectSelect(project);
- }
- toggleProject(project.name);
- })}
- >
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
- {editingProject === project.name ? (
-
-
setEditingName(e.target.value)}
- className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
- placeholder={t('projects.projectNamePlaceholder')}
- autoFocus
- onKeyDown={(e) => {
- if (e.key === 'Enter') saveProjectName(project.name);
- if (e.key === 'Escape') cancelEditing();
- }}
- />
-
- {project.fullPath}
-
-
- ) : (
-
-
- {project.displayName}
-
-
- {(() => {
- const sessionCount = getAllSessions(project).length;
- const hasMore = project.sessionMeta?.hasMore !== false;
- return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
- })()}
- {project.fullPath !== project.displayName && (
-
- • {project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
-
- )}
-
-
- )}
-
-
-
-
- {editingProject === project.name ? (
- <>
-
{
- e.stopPropagation();
- saveProjectName(project.name);
- }}
- >
-
-
-
{
- e.stopPropagation();
- cancelEditing();
- }}
- >
-
-
- >
- ) : (
- <>
- {/* Star button */}
-
{
- e.stopPropagation();
- toggleStarProject(project.name);
- }}
- title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
- >
-
-
-
{
- e.stopPropagation();
- startEditing(project);
- }}
- title={t('tooltips.renameProject')}
- >
-
-
-
{
- e.stopPropagation();
- deleteProject(project);
- }}
- title={t('tooltips.deleteProject')}
- >
-
-
- {isExpanded ? (
-
- ) : (
-
- )}
- >
- )}
-
-
-
-
- {/* 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 && (
-
{
- e.stopPropagation();
- showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
- }}
- onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
- >
-
-
- )}
-
-
-
-
- {/* Desktop Session Item */}
-
-
handleSessionClick(session, project.name)}
- onTouchEnd={handleTouchClick(() => handleSessionClick(session, project.name))}
- >
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
- {sessionName}
-
-
-
-
- {formatTimeAgo(sessionTime, currentTime, t)}
-
- {messageCount > 0 && (
-
- {messageCount}
-
- )}
-
- {isCursorSession ? (
-
- ) : isCodexSession ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {!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
- />
- {
- e.stopPropagation();
- updateSessionSummary(project.name, session.id, editingSessionName);
- }}
- title={t('tooltips.save')}
- >
-
-
- {
- e.stopPropagation();
- setEditingSession(null);
- setEditingSessionName('');
- }}
- title={t('tooltips.cancel')}
- >
-
-
- >
- ) : (
- <>
- {!isCodexSession && (
- {
- e.stopPropagation();
- setEditingSession(session.id);
- setEditingSessionName(session.summary || t('projects.newSession'));
- }}
- title={t('tooltips.editSessionName')}
- >
-
-
- )}
- {
- e.stopPropagation();
- showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
- }}
- title={t('tooltips.deleteSession')}
- >
-
-
- >
- )}
-
- )}
-
-
- );
- })
- )}
-
- {/* Show More Sessions Button */}
- {getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
-
loadMoreSessions(project)}
- disabled={loadingSessions[project.name]}
- >
- {loadingSessions[project.name] ? (
- <>
-
- {t('sessions.loading')}
- >
- ) : (
- <>
-
- {t('sessions.showMore')}
- >
- )}
-
- )}
-
- {/* Sessions - New Session Button */}
-
-
{
- handleProjectSelect(project);
- onNewSession(project);
- }}
- >
-
- {t('sessions.newSession')}
-
-
-
-
onNewSession(project)}
- >
-
- {t('sessions.newSession')}
-
-
- )}
-
- );
- })
- )}
-
-
-
- {/* // ! TODO: Move this to a new VersionUpdateNotification component */}
- {/* Version Update Notification */}
- {updateAvailable && (
-
- {/* Desktop Version Notification */}
-
-
setShowVersionModal(true)}
- >
-
-
-
- {releaseInfo?.title || `Version ${latestVersion}`}
-
-
{t('version.updateAvailable')}
-
-
-
-
- {/* Mobile Version Notification */}
-
-
setShowVersionModal(true)}
- >
-
-
-
- {releaseInfo?.title || `Version ${latestVersion}`}
-
-
{t('version.updateAvailable')}
-
-
-
-
- )}
-
- {/* Settings Section */}
-
- {/* Mobile Settings */}
-
-
-
-
-
- {t('actions.settings')}
-
-
-
- {/* Desktop Settings */}
-
-
- {t('actions.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 ? (
+
+
+
+ ) : (
+
+
{
+ event.stopPropagation();
+ setSidebarOpen(false);
+ }}
+ onTouchStart={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setSidebarOpen(false);
+ }}
+ aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
+ />
+ event.stopPropagation()}
+ onTouchStart={(event) => event.stopPropagation()}
+ >
+
+
+
+ )}
+
+
+ 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 && (
+
+
+
+
+
+
+ {releaseInfo?.title || `Version ${latestVersion}`}
+
+
{t('version.updateAvailable')}
+
+
+
+
+
+
+
+
+
+ {releaseInfo?.title || `Version ${latestVersion}`}
+
+
{t('version.updateAvailable')}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {t('actions.settings')}
+
+
+
+
+
+ {t('actions.settings')}
+
+
+ >
+ );
+}
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 (
+ <>
+
+
+ {!isLoading && !isMobile && (
+
+
+
+
+ {t('projects.newProject')}
+
+
+
+
+
+
+ )}
+
+ {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')}
+
+
+
+
+
+
+ {t('actions.cancel')}
+
+
+
+ {t('actions.delete')}
+
+
+
+
,
+ document.body,
+ )}
+
+ {sessionDeleteConfirmation &&
+ ReactDOM.createPortal(
+
+
+
+
+
+
+
+ {t('deleteConfirmation.deleteSession')}
+
+
+ {t('deleteConfirmation.confirmDelete')}{' '}
+
+ {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
+
+ ?
+
+
+ {t('deleteConfirmation.cannotUndo')}
+
+
+
+
+
+
+ {t('actions.cancel')}
+
+
+
+ {t('actions.delete')}
+
+
+
+
,
+ 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 ? (
+ <>
+
{
+ event.stopPropagation();
+ saveProjectName();
+ }}
+ >
+
+
+
{
+ event.stopPropagation();
+ onCancelEditingProject();
+ }}
+ >
+
+
+ >
+ ) : (
+ <>
+
{
+ event.stopPropagation();
+ toggleStarProject();
+ }}
+ onTouchEnd={touchHandlerFactory(toggleStarProject)}
+ title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
+ >
+
+
+
+
{
+ event.stopPropagation();
+ onDeleteProject(project);
+ }}
+ onTouchEnd={touchHandlerFactory(() => onDeleteProject(project))}
+ >
+
+
+
+
{
+ event.stopPropagation();
+ onStartEditingProject(project);
+ }}
+ onTouchEnd={touchHandlerFactory(() => onStartEditingProject(project))}
+ >
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+
+
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {isEditing ? (
+
+
onEditingNameChange(event.target.value)}
+ className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
+ placeholder={t('projects.projectNamePlaceholder')}
+ autoFocus
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ saveProjectName();
+ }
+ if (event.key === 'Escape') {
+ onCancelEditingProject();
+ }
+ }}
+ />
+
+ {project.fullPath}
+
+
+ ) : (
+
+
+ {project.displayName}
+
+
+ {sessionCountDisplay}
+ {project.fullPath !== project.displayName && (
+
+ {' - '}
+ {project.fullPath.length > 25 ? `...${project.fullPath.slice(-22)}` : project.fullPath}
+
+ )}
+
+
+ )}
+
+
+
+
+ {isEditing ? (
+ <>
+
{
+ event.stopPropagation();
+ saveProjectName();
+ }}
+ >
+
+
+
{
+ event.stopPropagation();
+ onCancelEditingProject();
+ }}
+ >
+
+
+ >
+ ) : (
+ <>
+
{
+ event.stopPropagation();
+ toggleStarProject();
+ }}
+ title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
+ >
+
+
+
{
+ event.stopPropagation();
+ onStartEditingProject(project);
+ }}
+ title={t('tooltips.renameProject')}
+ >
+
+
+
{
+ event.stopPropagation();
+ onDeleteProject(project);
+ }}
+ title={t('tooltips.deleteProject')}
+ >
+
+
+ {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 && (
+
onLoadMoreSessions(project)}
+ disabled={isLoadingSessions}
+ >
+ {isLoadingSessions ? (
+ <>
+
+ {t('sessions.loading')}
+ >
+ ) : (
+ <>
+
+ {t('sessions.showMore')}
+ >
+ )}
+
+ )}
+
+
+
{
+ onProjectSelect(project);
+ onNewSession(project);
+ }}
+ >
+
+ {t('sessions.newSession')}
+
+
+
+
onNewSession(project)}
+ >
+
+ {t('sessions.newSession')}
+
+
+ );
+}
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 && (
+
{
+ event.stopPropagation();
+ requestDeleteSession();
+ }}
+ onTouchEnd={touchHandlerFactory(requestDeleteSession)}
+ >
+
+
+ )}
+
+
+
+
+
+
onSessionSelect(session, project.name)}
+ onTouchEnd={touchHandlerFactory(() => onSessionSelect(session, project.name))}
+ >
+
+
+
+
{sessionView.sessionName}
+
+
+
+ {formatTimeAgo(sessionView.sessionTime, currentTime, t)}
+
+ {sessionView.messageCount > 0 && (
+
+ {sessionView.messageCount}
+
+ )}
+
+
+
+
+
+
+
+
+ {!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
+ />
+ {
+ event.stopPropagation();
+ saveEditedSession();
+ }}
+ title={t('tooltips.save')}
+ >
+
+
+ {
+ event.stopPropagation();
+ onCancelEditingSession();
+ }}
+ title={t('tooltips.cancel')}
+ >
+
+
+ >
+ ) : (
+ <>
+ {!sessionView.isCodexSession && (
+ {
+ event.stopPropagation();
+ onStartEditingSession(session.id, session.summary || t('projects.newSession'));
+ }}
+ title={t('tooltips.editSessionName')}
+ >
+
+
+ )}
+ {
+ event.stopPropagation();
+ requestDeleteSession();
+ }}
+ title={t('tooltips.deleteSession')}
+ >
+
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
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 (
-
- )
-})
-Button.displayName = "Button"
-
-export { Button, buttonVariants }
\ No newline at end of file
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..e07c177
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react';
+import { cva, type VariantProps } 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',
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Button.displayName = 'Button';
+
+export { Button, buttonVariants };
diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx
deleted file mode 100644
index a3c138e..0000000
--- a/src/components/ui/input.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react"
-import { cn } from "../../lib/utils"
-
-const Input = React.forwardRef(({ className, type, ...props }, ref) => {
- return (
-
- )
-})
-Input.displayName = "Input"
-
-export { Input }
\ No newline at end of file
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..9add5cf
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import { cn } from '../../lib/utils';
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/src/components/ui/scroll-area.jsx b/src/components/ui/scroll-area.jsx
deleted file mode 100644
index 334b36b..0000000
--- a/src/components/ui/scroll-area.jsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as React from "react"
-import { cn } from "../../lib/utils"
-
-const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
-
-))
-ScrollArea.displayName = "ScrollArea"
-
-export { ScrollArea }
\ No newline at end of file
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..e735e35
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { cn } from '../../lib/utils';
+
+export interface ScrollAreaProps extends React.HTMLAttributes {}
+
+const ScrollArea = React.forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ ),
+);
+ScrollArea.displayName = 'ScrollArea';
+
+export { ScrollArea };
diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts
new file mode 100644
index 0000000..4eee49a
--- /dev/null
+++ b/src/hooks/useProjectsState.ts
@@ -0,0 +1,527 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { NavigateFunction } from 'react-router-dom';
+import { api, authenticatedFetch } from '../utils/api';
+import type {
+ AppSocketMessage,
+ AppTab,
+ LoadingProgress,
+ Project,
+ ProjectSession,
+ ProjectsUpdatedMessage,
+} from '../types/app';
+
+type UseProjectsStateArgs = {
+ sessionId?: string;
+ navigate: NavigateFunction;
+ latestMessage: AppSocketMessage | null;
+ isMobile: boolean;
+ activeSessions: Set;
+};
+
+const serialize = (value: unknown) => JSON.stringify(value ?? null);
+
+const projectsHaveChanges = (
+ prevProjects: Project[],
+ nextProjects: Project[],
+ includeCursorSessions: boolean,
+): boolean => {
+ if (prevProjects.length !== nextProjects.length) {
+ return true;
+ }
+
+ return nextProjects.some((nextProject, index) => {
+ const prevProject = prevProjects[index];
+ if (!prevProject) {
+ return true;
+ }
+
+ const baseChanged =
+ nextProject.name !== prevProject.name ||
+ nextProject.displayName !== prevProject.displayName ||
+ nextProject.fullPath !== prevProject.fullPath ||
+ serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
+ serialize(nextProject.sessions) !== serialize(prevProject.sessions);
+
+ if (baseChanged) {
+ return true;
+ }
+
+ if (!includeCursorSessions) {
+ return false;
+ }
+
+ return serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions);
+ });
+};
+
+const getProjectSessions = (project: Project): ProjectSession[] => {
+ return [
+ ...(project.sessions ?? []),
+ ...(project.codexSessions ?? []),
+ ...(project.cursorSessions ?? []),
+ ];
+};
+
+const isUpdateAdditive = (
+ currentProjects: Project[],
+ updatedProjects: Project[],
+ selectedProject: Project | null,
+ selectedSession: ProjectSession | null,
+): boolean => {
+ if (!selectedProject || !selectedSession) {
+ return true;
+ }
+
+ const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name);
+ const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name);
+
+ if (!currentSelectedProject || !updatedSelectedProject) {
+ return false;
+ }
+
+ const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+ const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (!currentSelectedSession || !updatedSelectedSession) {
+ return false;
+ }
+
+ return (
+ currentSelectedSession.id === updatedSelectedSession.id &&
+ currentSelectedSession.title === updatedSelectedSession.title &&
+ currentSelectedSession.created_at === updatedSelectedSession.created_at &&
+ currentSelectedSession.updated_at === updatedSelectedSession.updated_at
+ );
+};
+
+const loadCursorSessionsForProjects = async (projects: Project[]): Promise => {
+ const projectsWithCursor = [...projects];
+
+ for (const project of projectsWithCursor) {
+ try {
+ const projectPath = project.fullPath || project.path;
+ const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(projectPath ?? '')}`;
+ const response = await authenticatedFetch(url);
+
+ if (!response.ok) {
+ project.cursorSessions = [];
+ continue;
+ }
+
+ const data = await response.json();
+ project.cursorSessions = data.success && Array.isArray(data.sessions) ? data.sessions : [];
+ } catch (error) {
+ console.error(`Error fetching Cursor sessions for project ${project.name}:`, error);
+ project.cursorSessions = [];
+ }
+ }
+
+ return projectsWithCursor;
+};
+
+export function useProjectsState({
+ sessionId,
+ navigate,
+ latestMessage,
+ isMobile,
+ activeSessions,
+}: UseProjectsStateArgs) {
+ const [projects, setProjects] = useState([]);
+ const [selectedProject, setSelectedProject] = useState(null);
+ const [selectedSession, setSelectedSession] = useState(null);
+ const [activeTab, setActiveTab] = useState('chat');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [isLoadingProjects, setIsLoadingProjects] = useState(true);
+ const [loadingProgress, setLoadingProgress] = useState(null);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+ const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
+ const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
+
+ const loadingProgressTimeoutRef = useRef | null>(null);
+
+ const fetchProjects = useCallback(async () => {
+ try {
+ setIsLoadingProjects(true);
+ const response = await api.projects();
+ const projectData = (await response.json()) as Project[];
+ const projectsWithCursor = await loadCursorSessionsForProjects(projectData);
+
+ setProjects((prevProjects) => {
+ if (prevProjects.length === 0) {
+ return projectsWithCursor;
+ }
+
+ return projectsHaveChanges(prevProjects, projectsWithCursor, true)
+ ? projectsWithCursor
+ : prevProjects;
+ });
+ } catch (error) {
+ console.error('Error fetching projects:', error);
+ } finally {
+ setIsLoadingProjects(false);
+ }
+ }, []);
+
+ const openSettings = useCallback((tab = 'tools') => {
+ setSettingsInitialTab(tab);
+ setShowSettings(true);
+ }, []);
+
+ useEffect(() => {
+ void fetchProjects();
+ }, [fetchProjects]);
+
+ useEffect(() => {
+ if (!latestMessage) {
+ return;
+ }
+
+ if (latestMessage.type === 'loading_progress') {
+ if (loadingProgressTimeoutRef.current) {
+ clearTimeout(loadingProgressTimeoutRef.current);
+ loadingProgressTimeoutRef.current = null;
+ }
+
+ setLoadingProgress(latestMessage as LoadingProgress);
+
+ if (latestMessage.phase === 'complete') {
+ loadingProgressTimeoutRef.current = setTimeout(() => {
+ setLoadingProgress(null);
+ loadingProgressTimeoutRef.current = null;
+ }, 500);
+ }
+
+ return;
+ }
+
+ if (latestMessage.type !== 'projects_updated') {
+ return;
+ }
+
+ const projectsMessage = latestMessage as ProjectsUpdatedMessage;
+
+ if (projectsMessage.changedFile && selectedSession && selectedProject) {
+ const normalized = projectsMessage.changedFile.replace(/\\/g, '/');
+ const changedFileParts = normalized.split('/');
+
+ if (changedFileParts.length >= 2) {
+ const filename = changedFileParts[changedFileParts.length - 1];
+ const changedSessionId = filename.replace('.jsonl', '');
+
+ if (changedSessionId === selectedSession.id) {
+ const isSessionActive = activeSessions.has(selectedSession.id);
+
+ if (!isSessionActive) {
+ setExternalMessageUpdate((prev) => prev + 1);
+ }
+ }
+ }
+ }
+
+ const hasActiveSession =
+ (selectedSession && activeSessions.has(selectedSession.id)) ||
+ (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
+
+ const updatedProjects = projectsMessage.projects;
+
+ if (
+ hasActiveSession &&
+ !isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
+ ) {
+ return;
+ }
+
+ setProjects(updatedProjects);
+
+ if (!selectedProject) {
+ return;
+ }
+
+ const updatedSelectedProject = updatedProjects.find(
+ (project) => project.name === selectedProject.name,
+ );
+
+ if (!updatedSelectedProject) {
+ return;
+ }
+
+ if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
+ setSelectedProject(updatedSelectedProject);
+ }
+
+ if (!selectedSession) {
+ return;
+ }
+
+ const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (!updatedSelectedSession) {
+ setSelectedSession(null);
+ }
+ }, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
+
+ useEffect(() => {
+ return () => {
+ if (loadingProgressTimeoutRef.current) {
+ clearTimeout(loadingProgressTimeoutRef.current);
+ loadingProgressTimeoutRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!sessionId || projects.length === 0) {
+ return;
+ }
+
+ const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
+
+ for (const project of projects) {
+ const claudeSession = project.sessions?.find((session) => session.id === sessionId);
+ if (claudeSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...claudeSession, __provider: 'claude' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+
+ const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
+ if (cursorSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...cursorSession, __provider: 'cursor' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+
+ const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
+ if (codexSession) {
+ const shouldUpdateProject = selectedProject?.name !== project.name;
+ const shouldUpdateSession =
+ selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
+
+ if (shouldUpdateProject) {
+ setSelectedProject(project);
+ }
+ if (shouldUpdateSession) {
+ setSelectedSession({ ...codexSession, __provider: 'codex' });
+ }
+ if (shouldSwitchTab) {
+ setActiveTab('chat');
+ }
+ return;
+ }
+ }
+ }, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
+
+ const handleProjectSelect = useCallback(
+ (project: Project) => {
+ setSelectedProject(project);
+ setSelectedSession(null);
+ navigate('/');
+
+ if (isMobile) {
+ setSidebarOpen(false);
+ }
+ },
+ [isMobile, navigate],
+ );
+
+ const handleSessionSelect = useCallback(
+ (session: ProjectSession) => {
+ setSelectedSession(session);
+
+ if (activeTab !== 'git' && activeTab !== 'preview') {
+ setActiveTab('chat');
+ }
+
+ const provider = localStorage.getItem('selected-provider') || 'claude';
+ if (provider === 'cursor') {
+ sessionStorage.setItem('cursorSessionId', session.id);
+ }
+
+ if (isMobile) {
+ const sessionProjectName = session.__projectName;
+ const currentProjectName = selectedProject?.name;
+
+ if (sessionProjectName !== currentProjectName) {
+ setSidebarOpen(false);
+ }
+ }
+
+ navigate(`/session/${session.id}`);
+ },
+ [activeTab, isMobile, navigate, selectedProject?.name],
+ );
+
+ const handleNewSession = useCallback(
+ (project: Project) => {
+ setSelectedProject(project);
+ setSelectedSession(null);
+ setActiveTab('chat');
+ navigate('/');
+
+ if (isMobile) {
+ setSidebarOpen(false);
+ }
+ },
+ [isMobile, navigate],
+ );
+
+ const handleSessionDelete = useCallback(
+ (sessionIdToDelete: string) => {
+ if (selectedSession?.id === sessionIdToDelete) {
+ setSelectedSession(null);
+ navigate('/');
+ }
+
+ setProjects((prevProjects) =>
+ prevProjects.map((project) => ({
+ ...project,
+ sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
+ sessionMeta: {
+ ...project.sessionMeta,
+ total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1),
+ },
+ })),
+ );
+ },
+ [navigate, selectedSession?.id],
+ );
+
+ const handleSidebarRefresh = useCallback(async () => {
+ try {
+ const response = await api.projects();
+ const freshProjects = (await response.json()) as Project[];
+
+ setProjects((prevProjects) =>
+ projectsHaveChanges(prevProjects, freshProjects, false) ? freshProjects : prevProjects,
+ );
+
+ if (!selectedProject) {
+ return;
+ }
+
+ const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name);
+ if (!refreshedProject) {
+ return;
+ }
+
+ if (serialize(refreshedProject) !== serialize(selectedProject)) {
+ setSelectedProject(refreshedProject);
+ }
+
+ if (!selectedSession) {
+ return;
+ }
+
+ const refreshedSession = refreshedProject.sessions?.find(
+ (session) => session.id === selectedSession.id,
+ );
+
+ if (refreshedSession && serialize(refreshedSession) !== serialize(selectedSession)) {
+ setSelectedSession(refreshedSession);
+ }
+ } catch (error) {
+ console.error('Error refreshing sidebar:', error);
+ }
+ }, [selectedProject, selectedSession]);
+
+ const handleProjectDelete = useCallback(
+ (projectName: string) => {
+ if (selectedProject?.name === projectName) {
+ setSelectedProject(null);
+ setSelectedSession(null);
+ navigate('/');
+ }
+
+ setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName));
+ },
+ [navigate, selectedProject?.name],
+ );
+
+ const sidebarSharedProps = useMemo(
+ () => ({
+ projects,
+ selectedProject,
+ selectedSession,
+ onProjectSelect: handleProjectSelect,
+ onSessionSelect: handleSessionSelect,
+ onNewSession: handleNewSession,
+ onSessionDelete: handleSessionDelete,
+ onProjectDelete: handleProjectDelete,
+ isLoading: isLoadingProjects,
+ loadingProgress,
+ onRefresh: handleSidebarRefresh,
+ onShowSettings: () => setShowSettings(true),
+ isMobile,
+ }),
+ [
+ handleNewSession,
+ handleProjectDelete,
+ handleProjectSelect,
+ handleSessionDelete,
+ handleSessionSelect,
+ handleSidebarRefresh,
+ isLoadingProjects,
+ isMobile,
+ loadingProgress,
+ projects,
+ selectedProject,
+ selectedSession,
+ ],
+ );
+
+ return {
+ projects,
+ selectedProject,
+ selectedSession,
+ activeTab,
+ sidebarOpen,
+ isLoadingProjects,
+ loadingProgress,
+ isInputFocused,
+ showSettings,
+ settingsInitialTab,
+ externalMessageUpdate,
+ setActiveTab,
+ setSidebarOpen,
+ setIsInputFocused,
+ setShowSettings,
+ openSettings,
+ fetchProjects,
+ sidebarSharedProps,
+ handleProjectSelect,
+ handleSessionSelect,
+ handleNewSession,
+ handleSessionDelete,
+ handleProjectDelete,
+ handleSidebarRefresh,
+ };
+}
diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts
new file mode 100644
index 0000000..0c3d1ba
--- /dev/null
+++ b/src/hooks/useSessionProtection.ts
@@ -0,0 +1,73 @@
+import { useCallback, useState } from 'react';
+
+export function useSessionProtection() {
+ const [activeSessions, setActiveSessions] = useState>(new Set());
+ const [processingSessions, setProcessingSessions] = useState>(new Set());
+
+ const markSessionAsActive = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => new Set([...prev, sessionId]));
+ }, []);
+
+ const markSessionAsInactive = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => {
+ const next = new Set(prev);
+ next.delete(sessionId);
+ return next;
+ });
+ }, []);
+
+ const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setProcessingSessions((prev) => new Set([...prev, sessionId]));
+ }, []);
+
+ const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
+ if (!sessionId) {
+ return;
+ }
+
+ setProcessingSessions((prev) => {
+ const next = new Set(prev);
+ next.delete(sessionId);
+ return next;
+ });
+ }, []);
+
+ const replaceTemporarySession = useCallback((realSessionId?: string | null) => {
+ if (!realSessionId) {
+ return;
+ }
+
+ setActiveSessions((prev) => {
+ const next = new Set();
+ for (const sessionId of prev) {
+ if (!sessionId.startsWith('new-session-')) {
+ next.add(sessionId);
+ }
+ }
+ next.add(realSessionId);
+ return next;
+ });
+ }, []);
+
+ return {
+ activeSessions,
+ processingSessions,
+ markSessionAsActive,
+ markSessionAsInactive,
+ markSessionAsProcessing,
+ markSessionAsNotProcessing,
+ replaceTemporarySession,
+ };
+}
diff --git a/src/hooks/useSidebarController.ts b/src/hooks/useSidebarController.ts
new file mode 100644
index 0000000..02aed2a
--- /dev/null
+++ b/src/hooks/useSidebarController.ts
@@ -0,0 +1,448 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type React from 'react';
+import type { TFunction } from 'i18next';
+import { api } from '../utils/api';
+import type { Project, ProjectSession } from '../types/app';
+import type {
+ AdditionalSessionsByProject,
+ DeleteProjectConfirmation,
+ LoadingSessionsByProject,
+ ProjectSortOrder,
+ SessionDeleteConfirmation,
+ SessionWithProvider,
+} from '../components/sidebar/types';
+import {
+ filterProjects,
+ getAllSessions,
+ loadStarredProjects,
+ persistStarredProjects,
+ readProjectSortOrder,
+ sortProjects,
+} from '../components/sidebar/utils';
+
+type UseSidebarControllerArgs = {
+ projects: Project[];
+ selectedProject: Project | null;
+ selectedSession: ProjectSession | null;
+ isLoading: boolean;
+ isMobile: boolean;
+ t: TFunction;
+ onRefresh: () => Promise | void;
+ onProjectSelect: (project: Project) => void;
+ onSessionSelect: (session: ProjectSession) => void;
+ onSessionDelete?: (sessionId: string) => void;
+ onProjectDelete?: (projectName: string) => void;
+ setCurrentProject: (project: Project) => void;
+ setSidebarVisible: (visible: boolean) => void;
+ sidebarVisible: boolean;
+};
+
+export function useSidebarController({
+ projects,
+ selectedProject,
+ selectedSession,
+ isLoading,
+ isMobile,
+ t,
+ onRefresh,
+ onProjectSelect,
+ onSessionSelect,
+ onSessionDelete,
+ onProjectDelete,
+ setCurrentProject,
+ setSidebarVisible,
+ sidebarVisible,
+}: UseSidebarControllerArgs) {
+ 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);
+ const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null);
+ const [showVersionModal, setShowVersionModal] = useState(false);
+ const [starredProjects, setStarredProjects] = useState>(() => loadStarredProjects());
+
+ const isSidebarCollapsed = !isMobile && !sidebarVisible;
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCurrentTime(new Date());
+ }, 60000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ setAdditionalSessions({});
+ setInitialSessionsLoaded(new Set());
+ }, [projects]);
+
+ useEffect(() => {
+ if (selectedSession && selectedProject) {
+ setExpandedProjects((prev) => {
+ if (prev.has(selectedProject.name)) {
+ return prev;
+ }
+ const next = new Set(prev);
+ next.add(selectedProject.name);
+ return next;
+ });
+ }
+ }, [selectedSession, selectedProject]);
+
+ useEffect(() => {
+ if (projects.length > 0 && !isLoading) {
+ const loadedProjects = new Set();
+ projects.forEach((project) => {
+ if (project.sessions && project.sessions.length >= 0) {
+ loadedProjects.add(project.name);
+ }
+ });
+ setInitialSessionsLoaded(loadedProjects);
+ }
+ }, [projects, isLoading]);
+
+ useEffect(() => {
+ const loadSortOrder = () => {
+ setProjectSortOrder(readProjectSortOrder());
+ };
+
+ loadSortOrder();
+
+ const handleStorageChange = (event: StorageEvent) => {
+ if (event.key === 'claude-settings') {
+ loadSortOrder();
+ }
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+
+ const interval = setInterval(() => {
+ if (document.hasFocus()) {
+ loadSortOrder();
+ }
+ }, 1000);
+
+ return () => {
+ window.removeEventListener('storage', handleStorageChange);
+ clearInterval(interval);
+ };
+ }, []);
+
+ const handleTouchClick = useCallback(
+ (callback: () => void) =>
+ (event: React.TouchEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ callback();
+ },
+ [],
+ );
+
+ const toggleProject = useCallback((projectName: string) => {
+ setExpandedProjects((prev) => {
+ const next = new Set();
+ if (!prev.has(projectName)) {
+ next.add(projectName);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleSessionClick = useCallback(
+ (session: SessionWithProvider, projectName: string) => {
+ onSessionSelect({ ...session, __projectName: projectName });
+ },
+ [onSessionSelect],
+ );
+
+ const toggleStarProject = useCallback((projectName: string) => {
+ setStarredProjects((prev) => {
+ const next = new Set(prev);
+ if (next.has(projectName)) {
+ next.delete(projectName);
+ } else {
+ next.add(projectName);
+ }
+
+ persistStarredProjects(next);
+ return next;
+ });
+ }, []);
+
+ const isProjectStarred = useCallback(
+ (projectName: string) => starredProjects.has(projectName),
+ [starredProjects],
+ );
+
+ const getProjectSessions = useCallback(
+ (project: Project) => getAllSessions(project, additionalSessions),
+ [additionalSessions],
+ );
+
+ const sortedProjects = useMemo(
+ () => sortProjects(projects, projectSortOrder, starredProjects, additionalSessions),
+ [additionalSessions, projectSortOrder, projects, starredProjects],
+ );
+
+ const filteredProjects = useMemo(
+ () => filterProjects(sortedProjects, searchFilter),
+ [searchFilter, sortedProjects],
+ );
+
+ const startEditing = useCallback((project: Project) => {
+ setEditingProject(project.name);
+ setEditingName(project.displayName);
+ }, []);
+
+ const cancelEditing = useCallback(() => {
+ setEditingProject(null);
+ setEditingName('');
+ }, []);
+
+ const saveProjectName = useCallback(
+ async (projectName: string) => {
+ try {
+ const response = await api.renameProject(projectName, editingName);
+ if (response.ok) {
+ if (window.refreshProjects) {
+ await window.refreshProjects();
+ } else {
+ window.location.reload();
+ }
+ } else {
+ console.error('Failed to rename project');
+ }
+ } catch (error) {
+ console.error('Error renaming project:', error);
+ } finally {
+ setEditingProject(null);
+ setEditingName('');
+ }
+ },
+ [editingName],
+ );
+
+ const showDeleteSessionConfirmation = useCallback(
+ (
+ projectName: string,
+ sessionId: string,
+ sessionTitle: string,
+ provider: SessionDeleteConfirmation['provider'] = 'claude',
+ ) => {
+ setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
+ },
+ [],
+ );
+
+ const confirmDeleteSession = useCallback(async () => {
+ if (!sessionDeleteConfirmation) {
+ return;
+ }
+
+ const { projectName, sessionId, provider } = sessionDeleteConfirmation;
+ setSessionDeleteConfirmation(null);
+
+ try {
+ const response =
+ provider === 'codex'
+ ? await api.deleteCodexSession(sessionId)
+ : await api.deleteSession(projectName, sessionId);
+
+ if (response.ok) {
+ onSessionDelete?.(sessionId);
+ } 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'));
+ }
+ }, [onSessionDelete, sessionDeleteConfirmation, t]);
+
+ const requestProjectDelete = useCallback(
+ (project: Project) => {
+ setDeleteConfirmation({
+ project,
+ sessionCount: getProjectSessions(project).length,
+ });
+ },
+ [getProjectSessions],
+ );
+
+ const confirmDeleteProject = useCallback(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) {
+ onProjectDelete?.(project.name);
+ } else {
+ const error = (await response.json()) as { error?: string };
+ 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;
+ });
+ }
+ }, [deleteConfirmation, onProjectDelete, t]);
+
+ const loadMoreSessions = useCallback(
+ async (project: Project) => {
+ 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) {
+ return;
+ }
+
+ const result = (await response.json()) as {
+ sessions?: ProjectSession[];
+ hasMore?: boolean;
+ };
+
+ setAdditionalSessions((prev) => ({
+ ...prev,
+ [project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
+ }));
+
+ if (result.hasMore === false) {
+ project.sessionMeta = { ...project.sessionMeta, hasMore: false };
+ }
+ } catch (error) {
+ console.error('Error loading more sessions:', error);
+ } finally {
+ setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
+ }
+ },
+ [additionalSessions, loadingSessions],
+ );
+
+ const handleProjectSelect = useCallback(
+ (project: Project) => {
+ onProjectSelect(project);
+ setCurrentProject(project);
+ },
+ [onProjectSelect, setCurrentProject],
+ );
+
+ const refreshProjects = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [onRefresh]);
+
+ const updateSessionSummary = useCallback(
+ async (_projectName: string, _sessionId: string, _summary: string) => {
+ // Session rename endpoint is not currently exposed on the API.
+ setEditingSession(null);
+ setEditingSessionName('');
+ },
+ [],
+ );
+
+ const collapseSidebar = useCallback(() => {
+ setSidebarVisible(false);
+ }, [setSidebarVisible]);
+
+ const expandSidebar = useCallback(() => {
+ setSidebarVisible(true);
+ }, [setSidebarVisible]);
+
+ return {
+ isSidebarCollapsed,
+ expandedProjects,
+ editingProject,
+ showNewProject,
+ editingName,
+ loadingSessions,
+ additionalSessions,
+ initialSessionsLoaded,
+ currentTime,
+ projectSortOrder,
+ isRefreshing,
+ editingSession,
+ editingSessionName,
+ searchFilter,
+ deletingProjects,
+ deleteConfirmation,
+ sessionDeleteConfirmation,
+ showVersionModal,
+ starredProjects,
+ filteredProjects,
+ handleTouchClick,
+ toggleProject,
+ handleSessionClick,
+ toggleStarProject,
+ isProjectStarred,
+ getProjectSessions,
+ startEditing,
+ cancelEditing,
+ saveProjectName,
+ showDeleteSessionConfirmation,
+ confirmDeleteSession,
+ requestProjectDelete,
+ confirmDeleteProject,
+ loadMoreSessions,
+ handleProjectSelect,
+ refreshProjects,
+ updateSessionSummary,
+ collapseSidebar,
+ expandSidebar,
+ setShowNewProject,
+ setEditingName,
+ setEditingSession,
+ setEditingSessionName,
+ setSearchFilter,
+ setDeleteConfirmation,
+ setSessionDeleteConfirmation,
+ setShowVersionModal,
+ };
+}
diff --git a/src/main.jsx b/src/main.jsx
index 02ca6b1..cacb2db 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
-import App from './App.jsx'
+import App from './App.tsx'
import './index.css'
import 'katex/dist/katex.min.css'
diff --git a/src/types/app.ts b/src/types/app.ts
new file mode 100644
index 0000000..4a0f8fa
--- /dev/null
+++ b/src/types/app.ts
@@ -0,0 +1,69 @@
+export type SessionProvider = 'claude' | 'cursor' | 'codex';
+
+export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview';
+
+export interface ProjectSession {
+ id: string;
+ title?: string;
+ summary?: string;
+ name?: string;
+ createdAt?: string;
+ created_at?: string;
+ updated_at?: string;
+ lastActivity?: string;
+ messageCount?: number;
+ __provider?: SessionProvider;
+ __projectName?: string;
+ [key: string]: unknown;
+}
+
+export interface ProjectSessionMeta {
+ total?: number;
+ hasMore?: boolean;
+ [key: string]: unknown;
+}
+
+export interface ProjectTaskmasterInfo {
+ hasTaskmaster?: boolean;
+ status?: string;
+ metadata?: Record;
+ [key: string]: unknown;
+}
+
+export interface Project {
+ name: string;
+ displayName: string;
+ fullPath: string;
+ path?: string;
+ sessions?: ProjectSession[];
+ cursorSessions?: ProjectSession[];
+ codexSessions?: ProjectSession[];
+ sessionMeta?: ProjectSessionMeta;
+ taskmaster?: ProjectTaskmasterInfo;
+ [key: string]: unknown;
+}
+
+export interface LoadingProgress {
+ type?: 'loading_progress';
+ phase?: string;
+ current: number;
+ total: number;
+ currentProject?: string;
+ [key: string]: unknown;
+}
+
+export interface ProjectsUpdatedMessage {
+ type: 'projects_updated';
+ projects: Project[];
+ changedFile?: string;
+ [key: string]: unknown;
+}
+
+export interface LoadingProgressMessage extends LoadingProgress {
+ type: 'loading_progress';
+}
+
+export type AppSocketMessage =
+ | LoadingProgressMessage
+ | ProjectsUpdatedMessage
+ | { type?: string; [key: string]: unknown };
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..465e5d1
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,9 @@
+export {};
+
+declare global {
+ interface Window {
+ __ROUTER_BASENAME__?: string;
+ refreshProjects?: () => void | Promise;
+ openSettings?: (tab?: string) => void;
+ }
+}