/** * Project Manager - Organizes sessions by project/folder * Inspired by CodeNomad's two-level tab system * https://github.com/NeuralNomadsAI/CodeNomad * * Provides: * - Project-level organization (top tabs) * - Session-level organization (second tabs) * - Easy switching between projects and sessions * - Project creation and management */ 'use strict'; // ============================================================ // Project Manager Class // ============================================================ class ProjectManager { constructor() { this.projects = new Map(); // Map this.activeProjectId = null; this.activeSessionId = null; this.initialized = false; this.closedProjects = new Set(); // Track closed project IDs this.STORAGE_KEY = 'claude_ide_closed_projects'; this.PROJECTS_STORAGE_KEY = 'claude_ide_projects'; // Store manually created projects } /** * Initialize the project manager */ async initialize() { if (this.initialized) return; console.log('[ProjectManager] Initializing...'); this.loadClosedProjects(); this.loadManuallyCreatedProjects(); // Load manually created projects first await this.loadProjects(); // Then load from sessions this.renderProjectTabs(); this.initialized = true; // Auto-select first project if available if (this.projects.size > 0 && !this.activeProjectId) { const firstProject = this.projects.values().next().value; this.switchProject(firstProject.id); } console.log('[ProjectManager] Initialized with', this.projects.size, 'projects'); } /** * Load manually created projects from localStorage */ loadManuallyCreatedProjects() { try { const stored = localStorage.getItem(this.PROJECTS_STORAGE_KEY); console.log('[ProjectManager] Checking localStorage for projects...'); console.log('[ProjectManager] Storage key:', this.PROJECTS_STORAGE_KEY); console.log('[ProjectManager] Found data:', stored ? 'YES' : 'NO'); if (stored) { const projectsData = JSON.parse(stored); console.log('[ProjectManager] Loading', projectsData.length, 'manually created projects from storage'); console.log('[ProjectManager] Projects data:', projectsData); projectsData.forEach(projectData => { const projectKey = projectData.id.replace('project-', ''); this.projects.set(projectKey, projectData); console.log('[ProjectManager] Loaded project:', projectData.name, 'with', projectData.sessions.length, 'sessions'); }); } else { console.log('[ProjectManager] No manually created projects found in storage'); } } catch (error) { console.error('[ProjectManager] Error loading manually created projects:', error); } } /** * Save manually created projects to localStorage */ saveManuallyCreatedProjects() { try { // Only save projects that were manually created (not auto-generated from workingDir) const manuallyCreatedProjects = Array.from(this.projects.values()) .filter(p => p.manuallyCreated === true); const dataToStore = JSON.stringify(manuallyCreatedProjects); localStorage.setItem(this.PROJECTS_STORAGE_KEY, dataToStore); console.log('[ProjectManager] Saved', manuallyCreatedProjects.length, 'manually created projects to storage'); console.log('[ProjectManager] Stored data:', dataToStore); } catch (error) { console.error('[ProjectManager] Error saving manually created projects:', error); console.error('[ProjectManager] localStorage available:', typeof localStorage !== 'undefined'); console.error('[ProjectManager] Storage key:', this.PROJECTS_STORAGE_KEY); } } /** * Load closed projects from localStorage */ loadClosedProjects() { try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { const closedIds = JSON.parse(stored); this.closedProjects = new Set(closedIds); console.log('[ProjectManager] Loaded', this.closedProjects.size, 'closed projects from storage'); } } catch (error) { console.error('[ProjectManager] Error loading closed projects:', error); this.closedProjects = new Set(); } } /** * Save closed projects to localStorage */ saveClosedProjects() { try { const closedIds = Array.from(this.closedProjects); localStorage.setItem(this.STORAGE_KEY, JSON.stringify(closedIds)); console.log('[ProjectManager] Saved', closedIds.length, 'closed projects to storage'); } catch (error) { console.error('[ProjectManager] Error saving closed projects:', error); } } /** * Load all sessions and organize them by project */ async loadProjects() { try { const res = await fetch('/claude/api/claude/sessions'); if (!res.ok) throw new Error('Failed to fetch sessions'); const data = await res.json(); // Combine active and historical sessions const allSessions = [ ...(data.active || []).map(s => ({...s, status: 'active'})), ...(data.historical || []).map(s => ({...s, status: 'historical'})) ]; // Group by working directory // CRITICAL FIX: Handle virtual projects by adding sessions directly to manually created projects const virtualSessions = []; // Store sessions with virtual workingDirs const grouped = new Map(); console.log('[ProjectManager] Processing', allSessions.length, 'total sessions'); allSessions.forEach(session => { const dir = session.workingDir || 'default'; const projectKey = dir.replace(/\//g, '-').replace(/^-/, '') || 'default'; // Check if this is a virtual workingDir if (dir.startsWith('/virtual/projects/')) { virtualSessions.push(session); return; // Don't add to grouped, will handle in manually created projects } if (!grouped.has(projectKey)) { const projectName = dir.split('/').pop() || 'Default'; const project = { id: `project-${projectKey}`, name: this.deduplicateProjectName(projectName, grouped), workingDir: dir, sessions: [], activeSessionId: null, createdAt: this.getOldestSessionTime(allSessions.filter(s => s.workingDir === dir)) }; grouped.set(projectKey, project); } grouped.get(projectKey).sessions.push(session); }); console.log('[ProjectManager] Separated', virtualSessions.length, 'virtual sessions and', grouped.size, 'real projects'); // Sort sessions by last activity within each project grouped.forEach(project => { project.sessions.sort((a, b) => { const dateA = new Date(a.lastActivity || a.createdAt || a.created_at || 0); const dateB = new Date(b.lastActivity || b.createdAt || b.created_at || 0); return dateB - dateA; }); // Set active session to most recent if (project.sessions.length > 0) { project.activeSessionId = project.sessions[0].id; } }); // Filter out closed projects const filtered = new Map(); grouped.forEach((project, key) => { if (!this.closedProjects.has(project.id)) { filtered.set(key, project); } }); // CRITICAL FIX: Merge with existing manually created projects // Add virtual sessions to their corresponding manually created projects const manuallyCreated = Array.from(this.projects.entries()) .filter(([key, p]) => p.manuallyCreated === true); manuallyCreated.forEach(([key, manualProject]) => { if (!filtered.has(key)) { // Project doesn't exist in filtered, just add it filtered.set(key, manualProject); console.log('[ProjectManager] Preserving manually created project:', manualProject.name); } else { // Project exists in filtered - for virtual projects, prefer manually created version if (manualProject.isVirtual) { // Replace with manually created version (which has correct name, etc.) filtered.set(key, manualProject); } } // Add virtual sessions that belong to this project const projectVirtualSessions = virtualSessions.filter(s => { const sessionProjectKey = s.workingDir?.replace('/virtual/projects/', '') || ''; return sessionProjectKey === key; }); if (projectVirtualSessions.length > 0) { console.log('[ProjectManager] Found', projectVirtualSessions.length, 'virtual sessions for project:', manualProject.name, 'key:', key); const existingSessionIds = new Set(manualProject.sessions.map(s => s.id)); projectVirtualSessions.forEach(session => { if (!existingSessionIds.has(session.id)) { manualProject.sessions.push(session); console.log('[ProjectManager] Added session', session.id, 'to virtual project:', manualProject.name); } }); // Sort sessions manualProject.sessions.sort((a, b) => { const dateA = new Date(a.lastActivity || a.createdAt || a.created_at || 0); const dateB = new Date(b.lastActivity || b.createdAt || b.created_at || 0); return dateB - dateA; }); // Update active session if (manualProject.sessions.length > 0) { manualProject.activeSessionId = manualProject.sessions[0].id; } } }); this.projects = filtered; console.log('[ProjectManager] Loaded', this.projects.size, 'projects (filtered out', grouped.size - this.projects.size, 'closed)'); } catch (error) { console.error('[ProjectManager] Error loading projects:', error); // Create default project on error this.projects.set('default', { id: 'project-default', name: 'Default', workingDir: '', sessions: [], activeSessionId: null, createdAt: Date.now() }); } } /** * Deduplicate project names by adding counter */ deduplicateProjectName(name, existingProjects) { const names = Array.from(existingProjects.values()).map(p => p.name); let finalName = name; let counter = 2; while (names.includes(finalName)) { finalName = `${name} (${counter})`; counter++; } return finalName; } /** * Get oldest session time for a project */ getOldestSessionTime(sessions) { if (sessions.length === 0) return Date.now(); return sessions.reduce((oldest, session) => { const time = new Date(session.createdAt || session.created_at || 0).getTime(); return time < oldest ? time : oldest; }, Infinity); } /** * Render project tabs */ renderProjectTabs() { const container = document.getElementById('project-tabs'); if (!container) { console.warn('[ProjectManager] Project tabs container not found'); return; } const projectsArray = Array.from(this.projects.values()); if (projectsArray.length === 0) { container.innerHTML = `
No projects yet
`; return; } container.innerHTML = `
${projectsArray.map(project => this.renderProjectTab(project)).join('')}
`; } /** * Render a single project tab */ renderProjectTab(project) { const isActive = project.id === this.activeProjectId; const sessionCount = project.sessions.length; return ` `; } /** * Switch to a different project */ async switchProject(projectId) { const project = this.projects.get(projectId.replace('project-', '')); if (!project) { console.warn('[ProjectManager] Project not found:', projectId); return; } console.log('[ProjectManager] Switching to project:', project.name, 'with', project.sessions.length, 'sessions'); this.activeProjectId = project.id; // Re-render project tabs to update active state this.renderProjectTabs(); // CRITICAL FIX: Update left sidebar chat history with this project's sessions if (typeof loadChatHistory === 'function') { await loadChatHistory(project.sessions); } // Update session tabs for this project if (window.sessionTabs) { window.sessionTabs.setSessions(project.sessions); window.sessionTabs.setActiveSession(project.activeSessionId); window.sessionTabs.render(); } // Attach to active session if exists if (project.activeSessionId && typeof attachToSession === 'function') { await attachToSession(project.activeSessionId); } else { // Show empty state this.showEmptyProjectState(project); } } /** * Show empty project state */ showEmptyProjectState(project) { const messagesContainer = document.getElementById('chat-messages'); if (!messagesContainer) return; messagesContainer.innerHTML = `
📁

${escapeHtml(project.name)}

No sessions yet in this project

`; } /** * Create a new project (select folder) */ async createNewProject() { console.log('[ProjectManager] Creating new project...'); // Prompt user for project name const projectName = prompt('Enter project name:', 'My Project'); if (!projectName || projectName.trim() === '') { console.log('[ProjectManager] Project creation cancelled'); return; } try { // Create a new session with the project name // This will automatically create a new project if needed const workingDir = this.projects.size > 0 ? Array.from(this.projects.values())[0].workingDir : '/home/uroma/obsidian-vault'; // Create a unique project key from the project name const projectKey = projectName.trim().replace(/\s+/g, '-').toLowerCase(); const newProjectId = `project-${projectKey}`; // CRITICAL FIX: Give each manually created project a unique virtual workingDir // This prevents sessions from other projects leaking into this project const virtualWorkingDir = `/virtual/projects/${projectKey}`; console.log('[ProjectManager] Creating project:', projectName, 'with key:', projectKey, 'and virtual workingDir:', virtualWorkingDir); // Create the project in memory if (!this.projects.has(projectKey)) { this.projects.set(projectKey, { id: newProjectId, name: this.deduplicateProjectName(projectName, this.projects), workingDir: virtualWorkingDir, // Use unique virtual workingDir sessions: [], activeSessionId: null, createdAt: Date.now(), manuallyCreated: true, // Mark as manually created for persistence isVirtual: true // Flag to identify virtual projects }); // CRITICAL FIX: Save to localStorage this.saveManuallyCreatedProjects(); // Re-render project tabs this.renderProjectTabs(); // Switch to the new project await this.switchProject(newProjectId); // Show success message if (typeof appendSystemMessage === 'function') { appendSystemMessage(`✅ Created project "${projectName}"`); } console.log('[ProjectManager] Project created successfully:', newProjectId); } else { this.showError('Project already exists'); } } catch (error) { console.error('[ProjectManager] Error creating project:', error); this.showError('Failed to create project: ' + error.message); } } /** * Create a new session in a specific folder * CRITICAL FIX: Added projectId parameter to associate sessions with their project */ async createSessionInFolder(workingDir, projectId = null, projectName = null) { try { if (typeof showLoadingOverlay === 'function') { showLoadingOverlay('Creating session...'); } // CRITICAL FIX: Add timeout to prevent hanging const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout // CRITICAL FIX: Include project metadata to properly associate session with project const sessionMetadata = { type: 'chat', source: 'web-ide' }; // Add project info to metadata if provided if (projectId && projectName) { sessionMetadata.projectId = projectId; sessionMetadata.project = projectName; console.log('[ProjectManager] Creating session in project:', projectName, 'with ID:', projectId); } const res = await fetch('/claude/api/claude/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ workingDir, metadata: sessionMetadata }) }); clearTimeout(timeoutId); // Clear timeout if request completes if (!res.ok) { const errorText = await res.text(); throw new Error(`HTTP ${res.status}: ${errorText}`); } const data = await res.json(); if (data.success || data.id) { // Reload projects and switch to new session await this.loadProjects(); await this.initialize(); // Find the new session and switch to it const session = data.session || data; for (const project of this.projects.values()) { const foundSession = project.sessions.find(s => s.id === session.id); if (foundSession) { this.switchProject(project.id); break; } } if (typeof hideLoadingOverlay === 'function') { hideLoadingOverlay(); } } } catch (error) { console.error('[ProjectManager] Error creating session:', error); if (typeof hideLoadingOverlay === 'function') { hideLoadingOverlay(); } // Special handling for timeout/abort errors if (error.name === 'AbortError') { this.showError('Request timed out. The server took too long to respond. Please try again.'); } else { this.showError('Failed to create session: ' + error.message); } } } /** * Create a new session in the current project * CRITICAL FIX: Pass project info to ensure sessions stay in their project */ async createNewSessionInProject(projectId) { const project = this.projects.get(projectId.replace('project-', '')); if (!project) return; // CRITICAL FIX: Pass project ID and name to associate session with this project await this.createSessionInFolder(project.workingDir, project.id, project.name); } /** * Get project for a session ID */ getProjectForSession(sessionId) { for (const project of this.projects.values()) { if (project.sessions.find(s => s.id === sessionId)) { return project; } } return null; } /** * Add a session to its project */ addSessionToProject(session) { const dir = session.workingDir || 'default'; const projectKey = dir.replace(/\//g, '-').replace(/^-/, '') || 'default'; if (!this.projects.has(projectKey)) { const projectName = dir.split('/').pop() || 'Default'; this.projects.set(projectKey, { id: `project-${projectKey}`, name: this.deduplicateProjectName(projectName, this.projects), workingDir: dir, sessions: [], activeSessionId: null, createdAt: Date.now() }); } const project = this.projects.get(projectKey); project.sessions.unshift(session); // Add to beginning project.activeSessionId = session.id; // Re-render if this is the active project if (this.activeProjectId === project.id) { this.renderProjectTabs(); // CRITICAL FIX: Update left sidebar chat history with this project's sessions if (typeof loadChatHistory === 'function') { loadChatHistory(project.sessions); } if (window.sessionTabs) { window.sessionTabs.setSessions(project.sessions); window.sessionTabs.render(); } } } /** * Update session in its project */ updateSessionInProject(session) { const project = this.getProjectForSession(session.id); if (project) { const index = project.sessions.findIndex(s => s.id === session.id); if (index !== -1) { project.sessions[index] = session; // Move to top project.sessions.splice(index, 1); project.sessions.unshift(session); } } } /** * Remove session from project */ removeSessionFromProject(sessionId) { const project = this.getProjectForSession(sessionId); if (project) { project.sessions = project.sessions.filter(s => s.id !== sessionId); if (project.activeSessionId === sessionId) { project.activeSessionId = project.sessions.length > 0 ? project.sessions[0].id : null; } } } /** * Show error message */ showError(message) { if (typeof showToast === 'function') { showToast(message, 'error'); } else { alert(message); } } /** * Close a project (with confirmation) */ async closeProject(projectId) { const projectKey = projectId.replace('project-', ''); const project = this.projects.get(projectKey); if (!project) return; // Check if project has sessions if (project.sessions.length > 0) { const sessionList = project.sessions.map(s => this.getSessionName(s)).join(', '); if (!confirm(`Close project "${project.name}"?\n\nThis project contains ${project.sessions.length} session(s):\n${sessionList}\n\nThe sessions will remain accessible but the project tab will be removed.`)) { return; } } else { if (!confirm(`Close project "${project.name}"?`)) { return; } } console.log('[ProjectManager] Closing project:', projectId); // Add to closed projects set and persist this.closedProjects.add(projectId); this.saveClosedProjects(); // Remove project from map this.projects.delete(projectKey); // If we closed the active project, switch to another if (this.activeProjectId === projectId) { const remainingProjects = Array.from(this.projects.values()); if (remainingProjects.length > 0) { // Switch to the next available project await this.switchProject(remainingProjects[0].id); } else { // No projects left this.activeProjectId = null; if (window.sessionTabs) { window.sessionTabs.setSessions([]); window.sessionTabs.render(); } // Show empty state const messagesContainer = document.getElementById('chat-messages'); if (messagesContainer) { messagesContainer.innerHTML = `
📁

No Projects

Create a new project to get started

`; } } } this.renderProjectTabs(); } /** * Get display name for a session */ getSessionName(session) { // Try to get name from metadata if (session.metadata) { if (session.metadata.project) return session.metadata.project; if (session.metadata.title) return session.metadata.title; if (session.metadata.name) return session.metadata.name; } // Use working directory if (session.workingDir) { return session.workingDir.split('/').pop(); } // Fallback to session ID return session.id.substring(0, 8); } /** * Refresh all projects */ async refresh() { await this.loadProjects(); this.renderProjectTabs(); if (this.activeProjectId) { const project = this.projects.get(this.activeProjectId.replace('project-', '')); if (project && window.sessionTabs) { window.sessionTabs.setSessions(project.sessions); window.sessionTabs.render(); } } } } // ============================================================ // Helper Functions // ============================================================ /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ============================================================ // Initialize Globally // ============================================================ window.projectManager = new ProjectManager(); // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => window.projectManager.initialize()); } else { window.projectManager.initialize(); } console.log('[ProjectManager] Module loaded');