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
This commit is contained in:
Eric Blanquer​
2026-01-18 04:58:03 +01:00
parent f8d1ec7b9e
commit 9e03acb0db
4 changed files with 138 additions and 19 deletions

View File

@@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd
// File system watcher for projects folder // File system watcher for projects folder
let projectsWatcher = null; let projectsWatcher = null;
const connectedClients = new Set(); 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 // Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() { async function setupProjectsWatcher() {
@@ -117,13 +131,19 @@ async function setupProjectsWatcher() {
const debouncedUpdate = async (eventType, filePath) => { const debouncedUpdate = async (eventType, filePath) => {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
// Prevent reentrant calls
if (isGetProjectsRunning) {
return;
}
try { try {
isGetProjectsRunning = true;
// Clear project directory cache when files change // Clear project directory cache when files change
clearProjectDirectoryCache(); clearProjectDirectoryCache();
// Get updated projects list // Get updated projects list
const updatedProjects = await getProjects(); const updatedProjects = await getProjects(broadcastProgress);
// Notify all connected clients about the project changes // Notify all connected clients about the project changes
const updateMessage = JSON.stringify({ const updateMessage = JSON.stringify({
@@ -142,6 +162,8 @@ async function setupProjectsWatcher() {
} catch (error) { } catch (error) {
console.error('[ERROR] Error handling project changes:', error); console.error('[ERROR] Error handling project changes:', error);
} finally {
isGetProjectsRunning = false;
} }
}, 300); // 300ms debounce (slightly faster than before) }, 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) => { app.get('/api/projects', authenticateToken, async (req, res) => {
try { try {
const projects = await getProjects(); const projects = await getProjects(broadcastProgress);
res.json(projects); res.json(projects);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -379,11 +379,14 @@ async function extractProjectDirectory(projectName) {
} }
} }
async function getProjects() { async function getProjects(progressCallback = null) {
const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig(); const config = await loadProjectConfig();
const projects = []; const projects = [];
const existingProjects = new Set(); const existingProjects = new Set();
let totalProjects = 0;
let processedProjects = 0;
let directories = [];
try { try {
// Check if the .claude/projects directory exists // Check if the .claude/projects directory exists
@@ -391,10 +394,31 @@ async function getProjects() {
// First, get existing Claude projects from the file system // First, get existing Claude projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true }); const entries = await fs.readdir(claudeDir, { withFileTypes: true });
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
});
}
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name); const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions // Extract actual project directory from JSONL sessions
@@ -461,19 +485,34 @@ async function getProjects() {
}; };
} }
projects.push(project); projects.push(project);
}
} }
} catch (error) { } catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
console.error('Error reading projects directory:', error); 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 // Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) { for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { 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 // Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath; let actualProjectDir = projectConfig.originalPath;
@@ -542,6 +581,15 @@ async function getProjects() {
} }
} }
// Emit completion after all projects (including manual) are processed
if (progressCallback) {
progressCallback({
phase: 'complete',
current: totalProjects,
total: totalProjects
});
}
return projects; return projects;
} }

View File

@@ -18,7 +18,7 @@
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). * 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 { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
@@ -53,6 +53,7 @@ function AppContent() {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
@@ -79,6 +80,9 @@ function AppContent() {
const { ws, sendMessage, messages } = useWebSocketContext(); const { ws, sendMessage, messages } = useWebSocketContext();
// Ref to track loading progress timeout for cleanup
const loadingProgressTimeoutRef = useRef(null);
// Detect if running as PWA // Detect if running as PWA
const [isPWA, setIsPWA] = useState(false); const [isPWA, setIsPWA] = useState(false);
@@ -171,6 +175,22 @@ function AppContent() {
if (messages.length > 0) { if (messages.length > 0) {
const latestMessage = messages[messages.length - 1]; 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') { if (latestMessage.type === 'projects_updated') {
// External Session Update Detection: Check if the changed file is the current session's JSONL // 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]); }, [messages, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => { const fetchProjects = async () => {
@@ -765,6 +792,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete} onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete} onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
@@ -859,6 +887,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete} onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete} onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}

View File

@@ -52,6 +52,7 @@ function Sidebar({
onSessionDelete, onSessionDelete,
onProjectDelete, onProjectDelete,
isLoading, isLoading,
loadingProgress,
onRefresh, onRefresh,
onShowSettings, onShowSettings,
updateAvailable, updateAvailable,
@@ -663,9 +664,28 @@ function Sidebar({
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> <div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div> </div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3> <h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
<p className="text-sm text-muted-foreground"> {loadingProgress && loadingProgress.total > 0 ? (
Fetching your Claude projects and sessions <div className="space-y-2">
</p> <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
className="bg-primary h-full transition-all duration-300 ease-out"
style={{ width: `${(loadingProgress.current / loadingProgress.total) * 100}%` }}
/>
</div>
<p className="text-sm text-muted-foreground">
{loadingProgress.current}/{loadingProgress.total} projects
</p>
{loadingProgress.currentProject && (
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
{loadingProgress.currentProject.split('-').slice(-2).join('/')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions
</p>
)}
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4"> <div className="text-center py-12 md:py-8 px-4">