/** * 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 non-blocking confirmation modal) */ async closeSession(sessionId) { const session = this.sessions.find(s => s.id === sessionId); if (!session) return; const sessionName = this.getSessionName(session); // Show non-blocking confirmation modal const confirmed = await this.showConfirmModal( 'Close Session', `Are you sure you want to close "${escapeHtml(sessionName)}"?`, 'Close Session' ); if (!confirmed) { 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(); } /** * Show a non-blocking confirmation modal * @param {string} title - Modal title * @param {string} message - Confirmation message * @param {string} confirmText - Text for confirm button (default: "Confirm") * @returns {Promise} - True if confirmed, false otherwise */ showConfirmModal(title, message, confirmText = 'Confirm') { return new Promise((resolve) => { // Remove existing modal if present const existingModal = document.getElementById('confirm-modal-overlay'); if (existingModal) existingModal.remove(); // Create overlay const overlay = document.createElement('div'); overlay.id = 'confirm-modal-overlay'; overlay.className = 'confirm-modal-overlay'; // Create modal const modal = document.createElement('div'); modal.className = 'confirm-modal'; modal.innerHTML = `

${escapeHtml(title)}

${message}

`; overlay.appendChild(modal); document.body.appendChild(overlay); // Prevent body scroll document.body.style.overflow = 'hidden'; // Trigger animation setTimeout(() => { overlay.classList.add('visible'); modal.classList.add('visible'); }, 10); // Handle confirm button document.getElementById('confirm-modal-ok').addEventListener('click', () => { closeModal(true); }); // Handle cancel button document.getElementById('confirm-modal-cancel').addEventListener('click', () => { closeModal(false); }); // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) { closeModal(false); } }); // Close on Escape key const escapeHandler = (e) => { if (e.key === 'Escape') { closeModal(false); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('keydown', escapeHandler); // Function to close modal function closeModal(result) { overlay.classList.remove('visible'); modal.classList.remove('visible'); setTimeout(() => { document.removeEventListener('keydown', escapeHandler); overlay.remove(); document.body.style.overflow = ''; resolve(result); }, 200); } }); } /** * 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); const confirmed = await this.showConfirmModal( 'Delete Session', `Are you sure you want to permanently delete "${escapeHtml(sessionName)}"? This action cannot be undone.`, 'Delete' ); if (!confirmed) { 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(); } // ============================================================ // Add CSS Styles // ============================================================ (function() { const style = document.createElement('style'); style.textContent = ` /* Confirm Modal Overlay */ .confirm-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 20px; opacity: 0; transition: opacity 0.2s ease; } .confirm-modal-overlay.visible { opacity: 1; } /* Confirm Modal */ .confirm-modal { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); width: 100%; max-width: 400px; overflow: hidden; transform: scale(0.95); transition: transform 0.2s ease; } .confirm-modal.visible { transform: scale(1); } /* Header */ .confirm-modal-header { padding: 20px 20px 12px 20px; border-bottom: 1px solid #333; } .confirm-modal-title { font-size: 18px; font-weight: 600; color: #e0e0e0; margin: 0; } /* Body */ .confirm-modal-body { padding: 20px; } .confirm-modal-message { font-size: 14px; color: #b0b0b0; margin: 0; line-height: 1.5; } /* Footer */ .confirm-modal-footer { padding: 12px 20px 20px 20px; display: flex; justify-content: flex-end; gap: 12px; } /* Buttons */ .btn-confirm-cancel, .btn-confirm-ok { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; border: none; } .btn-confirm-cancel { background: transparent; border: 1px solid #333; color: #e0e0e0; } .btn-confirm-cancel:hover { background: #252525; border-color: #444; } .btn-confirm-ok { background: linear-gradient(135deg, #4a9eff 0%, #a78bfa 100%); color: white; } .btn-confirm-ok:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(74, 158, 255, 0.4); } .btn-confirm-ok:active { transform: translateY(0); } /* Responsive */ @media (max-width: 640px) { .confirm-modal { max-width: 90%; } .confirm-modal-header, .confirm-modal-body, .confirm-modal-footer { padding: 16px; } .confirm-modal-footer { flex-direction: column-reverse; } .btn-confirm-cancel, .btn-confirm-ok { width: 100%; } } `; document.head.appendChild(style); })(); console.log('[SessionTabs] Module loaded');