/** * Session Tabs - Manages session tabs within a project * Inspired by CodeNomad's two-level tab system * https://github.com/NeuralNomadsAI/CodeNomad * * Provides: * - Session tabs for the active project * - Session switching * - Session creation, renaming, deletion * - Visual indicators for active sessions */ 'use strict'; // ============================================================ // Session Tabs Class // ============================================================ class SessionTabs { constructor() { this.sessions = []; this.activeSessionId = null; this.initialized = false; } /** * Initialize session tabs */ initialize() { if (this.initialized) return; console.log('[SessionTabs] Initializing...'); this.render(); this.initialized = true; } /** * Set sessions for current project */ setSessions(sessions) { this.sessions = sessions || []; console.log('[SessionTabs] Set', this.sessions.length, 'sessions'); } /** * Set active session */ setActiveSession(sessionId) { this.activeSessionId = sessionId; console.log('[SessionTabs] Active session:', sessionId); } /** * Render session tabs */ render() { const container = document.getElementById('session-tabs'); if (!container) { console.warn('[SessionTabs] Session tabs container not found'); return; } if (this.sessions.length === 0) { container.innerHTML = `
No sessions
`; return; } container.innerHTML = `
${this.sessions.map(session => this.renderSessionTab(session)).join('')}
`; } /** * Render a single session tab */ renderSessionTab(session) { const isActive = session.id === this.activeSessionId; const isRunning = session.status === 'running'; const sessionName = this.getSessionName(session); const relativeTime = this.getRelativeTime(session); return ` `; } /** * 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); } /** * Get relative time string */ getRelativeTime(session) { const date = new Date(session.lastActivity || session.createdAt || session.created_at || Date.now()); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Switch to a different session */ async switchSession(sessionId) { console.log('[SessionTabs] Switching to session:', sessionId); this.activeSessionId = sessionId; this.render(); // Attach to the session if (typeof attachToSession === 'function') { await attachToSession(sessionId); } } /** * Create a new session */ async createNewSession() { console.log('[SessionTabs] Creating new session'); // Use project manager to create session in current project if (window.projectManager && window.projectManager.activeProjectId) { await window.projectManager.createNewSessionInProject(window.projectManager.activeProjectId); } else { // Create in default location await window.projectManager.createNewSessionInProject('default'); } } /** * Close a session (with confirmation) */ async closeSession(sessionId) { const session = this.sessions.find(s => s.id === sessionId); if (!session) return; const sessionName = this.getSessionName(session); // Confirm before closing if (!confirm(`Close session "${sessionName}"?`)) { return; } console.log('[SessionTabs] Closing session:', sessionId); // Note: This just removes the tab from view // The session still exists on the server this.sessions = this.sessions.filter(s => s.id !== sessionId); if (this.activeSessionId === sessionId) { this.activeSessionId = this.sessions.length > 0 ? this.sessions[0].id : null; if (this.activeSessionId) { await this.switchSession(this.activeSessionId); } else { // Show empty state if (window.projectManager) { const project = window.projectManager.getProjectForSession(sessionId); if (project) { window.projectManager.showEmptyProjectState(project); } } } } this.render(); } /** * Show context menu for session */ showContextMenu(event, sessionId) { event.preventDefault(); const session = this.sessions.find(s => s.id === sessionId); if (!session) return; // Remove existing menu const existingMenu = document.getElementById('session-context-menu'); if (existingMenu) existingMenu.remove(); // Create context menu const menu = document.createElement('div'); menu.id = 'session-context-menu'; menu.className = 'context-menu'; menu.innerHTML = `
✏️ Rename
📋 Duplicate
🗑️ Delete
Close
`; // Position menu const rect = event.target.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; menu.style.left = `${rect.left}px`; menu.style.minWidth = '150px'; document.body.appendChild(menu); // Add event listeners menu.querySelector('[data-action="rename"]').addEventListener('click', () => { this.renameSession(session); this.closeContextMenu(); }); menu.querySelector('[data-action="duplicate"]').addEventListener('click', () => { this.duplicateSession(session); this.closeContextMenu(); }); menu.querySelector('[data-action="delete"]').addEventListener('click', () => { this.deleteSession(session); this.closeContextMenu(); }); menu.querySelector('[data-action="close"]').addEventListener('click', () => { this.closeSession(session.id); this.closeContextMenu(); }); // Close menu on click outside setTimeout(() => { document.addEventListener('click', this.closeContextMenu, { once: true }); }, 10); } /** * Close context menu */ closeContextMenu() { document.getElementById('session-context-menu')?.remove(); } /** * Rename a session */ async renameSession(session) { const currentName = this.getSessionName(session); const newName = prompt('Enter new name:', currentName); if (newName && newName !== currentName) { console.log('[SessionTabs] Renaming session to:', newName); // Update metadata if (!session.metadata) { session.metadata = {}; } session.metadata.project = newName; // Use 'project' field as name // Update on server try { await fetch(`/claude/api/claude/sessions/${session.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata: session.metadata }) }); // Refresh tabs this.render(); if (window.projectManager) { await window.projectManager.refresh(); } } catch (error) { console.error('[SessionTabs] Error renaming session:', error); } } } /** * Duplicate a session */ async duplicateSession(session) { console.log('[SessionTabs] Duplicating session:', session.id); // Create new session with same metadata try { const res = await fetch('/claude/api/claude/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: session.workingDir, metadata: { ...session.metadata, project: `${this.getSessionName(session)} (copy)`, duplicatedFrom: session.id } }) }); if (res.ok) { const data = await res.json(); if (data.success && window.projectManager) { await window.projectManager.refresh(); // Switch to new session await this.switchSession(data.session.id); } } } catch (error) { console.error('[SessionTabs] Error duplicating session:', error); } } /** * Delete a session */ async deleteSession(session) { const sessionName = this.getSessionName(session); if (!confirm(`Permanently delete "${sessionName}"? This cannot be undone.`)) { return; } console.log('[SessionTabs] Deleting session:', session.id); try { await fetch(`/claude/api/claude/sessions/${session.id}`, { method: 'DELETE' }); // Refresh if (window.projectManager) { await window.projectManager.refresh(); } } catch (error) { console.error('[SessionTabs] Error deleting session:', error); } } /** * Update session (e.g., when it receives new activity) */ updateSession(session) { const index = this.sessions.findIndex(s => s.id === session.id); if (index !== -1) { this.sessions[index] = session; // Move to top this.sessions.splice(index, 1); this.sessions.unshift(session); this.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.sessionTabs = new SessionTabs(); // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => window.sessionTabs.initialize()); } else { window.sessionTabs.initialize(); } console.log('[SessionTabs] Module loaded');