From 515ad3b3369a74db01cd0893cce2b88b56f27115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Blanquer=E2=80=8B?= Date: Tue, 20 Jan 2026 23:39:24 +0100 Subject: [PATCH 1/2] fix: hide session badge and icon on hover to show action buttons Hide the message count badge and provider icon when hovering over a session, so they don't overlap with the edit/delete action buttons. --- src/components/Sidebar.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index b06f56e..9b53fd9 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1166,11 +1166,11 @@ function Sidebar({ {formatTimeAgo(sessionTime, currentTime)} {messageCount > 0 && ( - + {messageCount} )} - + {isCursorSession ? ( ) : isCodexSession ? ( From 9e03acb0db993c124acdf7d56df48564b1a05dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Blanquer=E2=80=8B?= Date: Sun, 18 Jan 2026 04:58:03 +0100 Subject: [PATCH 2/2] Add loading progress indicator Adds real-time progress feedback when loading projects: - Server broadcasts progress updates via WebSocket - Sidebar shows current project being loaded with progress bar - Fixed progress counter to show correct current/total - Completion event now fires after all projects (including manual) are processed - Race condition fix for timeout cleanup using ref - Added cleanup function to prevent state update on unmounted component --- server/index.js | 26 ++++++++++++-- server/projects.js | 70 ++++++++++++++++++++++++++++++++------ src/App.jsx | 35 +++++++++++++++++-- src/components/Sidebar.jsx | 26 ++++++++++++-- 4 files changed, 138 insertions(+), 19 deletions(-) diff --git a/server/index.js b/server/index.js index 25b25fd..91b8b64 100755 --- a/server/index.js +++ b/server/index.js @@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd // File system watcher for projects folder let projectsWatcher = null; const connectedClients = new Set(); +let isGetProjectsRunning = false; // Flag to prevent reentrant calls + +// Broadcast progress to all connected WebSocket clients +function broadcastProgress(progress) { + const message = JSON.stringify({ + type: 'loading_progress', + ...progress + }); + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} // Setup file system watcher for Claude projects folder using chokidar async function setupProjectsWatcher() { @@ -117,13 +131,19 @@ async function setupProjectsWatcher() { const debouncedUpdate = async (eventType, filePath) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { + // Prevent reentrant calls + if (isGetProjectsRunning) { + return; + } + try { + isGetProjectsRunning = true; // Clear project directory cache when files change clearProjectDirectoryCache(); // Get updated projects list - const updatedProjects = await getProjects(); + const updatedProjects = await getProjects(broadcastProgress); // Notify all connected clients about the project changes const updateMessage = JSON.stringify({ @@ -142,6 +162,8 @@ async function setupProjectsWatcher() { } catch (error) { console.error('[ERROR] Error handling project changes:', error); + } finally { + isGetProjectsRunning = false; } }, 300); // 300ms debounce (slightly faster than before) }; @@ -366,7 +388,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => { app.get('/api/projects', authenticateToken, async (req, res) => { try { - const projects = await getProjects(); + const projects = await getProjects(broadcastProgress); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); diff --git a/server/projects.js b/server/projects.js index ff7a8d1..c6deeac 100755 --- a/server/projects.js +++ b/server/projects.js @@ -379,22 +379,46 @@ async function extractProjectDirectory(projectName) { } } -async function getProjects() { +async function getProjects(progressCallback = null) { const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); - + let totalProjects = 0; + let processedProjects = 0; + let directories = []; + try { // Check if the .claude/projects directory exists await fs.access(claudeDir); - + // First, get existing Claude projects from the file system const entries = await fs.readdir(claudeDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - existingProjects.add(entry.name); + directories = entries.filter(e => e.isDirectory()); + + // Build set of existing project names for later + directories.forEach(e => existingProjects.add(e.name)); + + // Count manual projects not already in directories + const manualProjectsCount = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) + .length; + + totalProjects = directories.length + manualProjectsCount; + + for (const entry of directories) { + processedProjects++; + + // Emit progress + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: entry.name + }); + } + const projectPath = path.join(claudeDir, entry.name); // Extract actual project directory from JSONL sessions @@ -460,20 +484,35 @@ async function getProjects() { status: 'error' }; } - - projects.push(project); - } + + projects.push(project); } } catch (error) { // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects if (error.code !== 'ENOENT') { console.error('Error reading projects directory:', error); } + // Calculate total for manual projects only (no directories exist) + totalProjects = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded) + .length; } // Add manually configured projects that don't exist as folders yet for (const [projectName, projectConfig] of Object.entries(config)) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { + processedProjects++; + + // Emit progress for manual projects + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectName + }); + } + // Use the original path if available, otherwise extract from potential sessions let actualProjectDir = projectConfig.originalPath; @@ -541,7 +580,16 @@ async function getProjects() { projects.push(project); } } - + + // Emit completion after all projects (including manual) are processed + if (progressCallback) { + progressCallback({ + phase: 'complete', + current: totalProjects, + total: totalProjects + }); + } + return projects; } diff --git a/src/App.jsx b/src/App.jsx index ed0ef58..42e4e67 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,7 @@ * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; import Sidebar from './components/Sidebar'; @@ -53,6 +53,7 @@ function AppContent() { const [isMobile, setIsMobile] = useState(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'); @@ -78,7 +79,10 @@ function AppContent() { const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const { ws, sendMessage, messages } = useWebSocketContext(); - + + // Ref to track loading progress timeout for cleanup + const loadingProgressTimeoutRef = useRef(null); + // Detect if running as PWA const [isPWA, setIsPWA] = useState(false); @@ -170,7 +174,23 @@ function AppContent() { useEffect(() => { if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; - + + // 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 @@ -247,6 +267,13 @@ function AppContent() { } } } + + return () => { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); + loadingProgressTimeoutRef.current = null; + } + }; }, [messages, selectedProject, selectedSession, activeSessions]); const fetchProjects = async () => { @@ -765,6 +792,7 @@ function AppContent() { onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} isLoading={isLoadingProjects} + loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} onShowSettings={() => setShowSettings(true)} updateAvailable={updateAvailable} @@ -859,6 +887,7 @@ function AppContent() { onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} isLoading={isLoadingProjects} + loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} onShowSettings={() => setShowSettings(true)} updateAvailable={updateAvailable} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index b06f56e..223ca9b 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -52,6 +52,7 @@ function Sidebar({ onSessionDelete, onProjectDelete, isLoading, + loadingProgress, onRefresh, onShowSettings, updateAvailable, @@ -663,9 +664,28 @@ function Sidebar({

Loading projects...

-

- Fetching your Claude projects and sessions -

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

+ {loadingProgress.current}/{loadingProgress.total} projects +

+ {loadingProgress.currentProject && ( +

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

+ )} +
+ ) : ( +

+ Fetching your Claude projects and sessions +

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