/** * 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; } /** * Initialize the project manager */ async initialize() { if (this.initialized) return; console.log('[ProjectManager] Initializing...'); await this.loadProjects(); 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 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 const grouped = new Map(); allSessions.forEach(session => { const dir = session.workingDir || 'default'; const projectKey = dir.replace(/\//g, '-').replace(/^-/, '') || 'default'; 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); }); // 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; } }); this.projects = grouped; console.log('[ProjectManager] Loaded', this.projects.size, 'projects'); } 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); this.activeProjectId = project.id; // Re-render project tabs to update active state this.renderProjectTabs(); // 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() { // Trigger folder picker if available if (window.folderPicker && typeof window.folderPicker.pick === 'function') { try { const folder = await window.folderPicker.pick(); if (folder) { await this.createSessionInFolder(folder); } } catch (error) { console.error('[ProjectManager] Error picking folder:', error); this.showError('Failed to select folder'); } } else { // Fallback: prompt for folder or create default session await this.createNewSessionInProject('default'); } } /** * Create a new session in a specific folder */ async createSessionInFolder(workingDir) { try { if (typeof showLoadingOverlay === 'function') { showLoadingOverlay('Creating session...'); } const res = await fetch('/claude/api/claude/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, metadata: { type: 'chat', source: 'web-ide' } }) }); if (!res.ok) throw new Error('Failed to create session'); const data = await res.json(); if (data.success) { // Reload projects and switch to new session await this.loadProjects(); await this.initialize(); // Find the new session and switch to it for (const project of this.projects.values()) { const session = project.sessions.find(s => s.id === data.session.id); if (session) { this.switchProject(project.id); break; } } if (typeof hideLoadingOverlay === 'function') { hideLoadingOverlay(); } } } catch (error) { console.error('[ProjectManager] Error creating session:', error); if (typeof hideLoadingOverlay === 'function') { hideLoadingOverlay(); } this.showError('Failed to create session'); } } /** * Create a new session in the current project */ async createNewSessionInProject(projectId) { const project = this.projects.get(projectId.replace('project-', '')); if (!project) return; await this.createSessionInFolder(project.workingDir); } /** * 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(); 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); // 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');