diff --git a/public/claude-ide/index.html b/public/claude-ide/index.html index 674ef7d8..71b8d28e 100644 --- a/public/claude-ide/index.html +++ b/public/claude-ide/index.html @@ -233,7 +233,7 @@ + + diff --git a/public/claude-ide/project-manager.js b/public/claude-ide/project-manager.js new file mode 100644 index 00000000..3992875a --- /dev/null +++ b/public/claude-ide/project-manager.js @@ -0,0 +1,458 @@ +/** + * 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); + } + } + + /** + * 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'); diff --git a/public/claude-ide/project-tabs.css b/public/claude-ide/project-tabs.css new file mode 100644 index 00000000..4f35073c --- /dev/null +++ b/public/claude-ide/project-tabs.css @@ -0,0 +1,471 @@ +/** + * Project and Session Tabs Styling + * Inspired by CodeNomad's two-level tab system + * https://github.com/NeuralNomadsAI/CodeNomad + */ + +/* ============================================================ + Project Tabs (Level 1) + ============================================================ */ + +#project-tabs { + background: #1a1a1a; + border-bottom: 1px solid #333; + padding: 0; +} + +.project-tabs { + display: flex; + align-items: center; + padding: 0 8px; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: #444 #1a1a1a; +} + +.project-tabs::-webkit-scrollbar { + height: 6px; +} + +.project-tabs::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.project-tabs::-webkit-scrollbar-thumb { + background: #444; + border-radius: 3px; +} + +.project-tabs::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Project Tab Button */ +.project-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; + color: #888; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + min-width: 0; +} + +.project-tab:hover { + background: #252525; + color: #e0e0e0; +} + +.project-tab.active { + background: #222; + color: #4a9eff; + border-bottom-color: #4a9eff; +} + +.project-tab .tab-icon { + font-size: 14px; + flex-shrink: 0; +} + +.project-tab .tab-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.project-tab .tab-count { + font-size: 11px; + background: #333; + color: #888; + padding: 2px 6px; + border-radius: 10px; + flex-shrink: 0; +} + +.project-tab.active .tab-count { + background: rgba(74, 158, 255, 0.2); + color: #4a9eff; +} + +/* New Project Tab */ +.project-tab-new { + color: #51cf66; +} + +.project-tab-new:hover { + background: rgba(81, 207, 102, 0.1); + color: #51cf66; +} + +/* Empty State */ +.project-tabs-empty { + padding: 12px 16px; + color: #666; + font-size: 13px; + font-style: italic; +} + +/* ============================================================ + Session Tabs (Level 2) + ============================================================ */ + +#session-tabs { + background: #151515; + border-bottom: 1px solid #333; + padding: 0; +} + +.session-tabs { + display: flex; + align-items: center; + padding: 0 8px; + gap: 2px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: #444 #151515; +} + +.session-tabs::-webkit-scrollbar { + height: 4px; +} + +.session-tabs::-webkit-scrollbar-track { + background: #151515; +} + +.session-tabs::-webkit-scrollbar-thumb { + background: #444; + border-radius: 2px; +} + +.session-tabs::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Session Tab Button */ +.session-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; + color: #888; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + min-width: 0; + position: relative; +} + +.session-tab:hover { + background: #222; + color: #e0e0e0; +} + +.session-tab:hover .tab-close { + opacity: 1; +} + +.session-tab.active { + background: #1a1a1a; + color: #e0e0e0; + border-bottom-color: #51cf66; +} + +.session-tab.running { + color: #4a9eff; +} + +.session-tab.running .tab-indicator { + display: block; +} + +.session-tab .tab-icon { + font-size: 12px; + flex-shrink: 0; +} + +.session-tab .tab-label { + flex: 1; + min-width: 0; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-tab .tab-indicator { + display: none; + width: 6px; + height: 6px; + background: #4a9eff; + border-radius: 50%; + flex-shrink: 0; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +.session-tab .tab-close { + opacity: 0; + font-size: 16px; + line-height: 1; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; + transition: all 0.2s ease; +} + +.session-tab .tab-close:hover { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + +/* New Session Tab */ +.session-tab-new { + color: #51cf66; + padding: 8px 12px; +} + +.session-tab-new:hover { + background: rgba(81, 207, 102, 0.1); + color: #51cf66; +} + +/* Empty State */ +.session-tabs-empty { + padding: 10px 16px; + color: #666; + font-size: 12px; + font-style: italic; +} + +/* ============================================================ + Context Menu + ============================================================ */ + +.context-menu { + position: fixed; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 10000; + min-width: 180px; + padding: 4px 0; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: none; + border: none; + color: #e0e0e0; + font-size: 13px; + cursor: pointer; + transition: background 0.2s ease; + width: 100%; + text-align: left; +} + +.context-menu-item:hover { + background: #3a3a3a; +} + +.context-menu-item.danger { + color: #ff6b6b; +} + +.context-menu-item.danger:hover { + background: rgba(255, 107, 107, 0.1); +} + +.context-menu-divider { + height: 1px; + background: #444; + margin: 4px 0; +} + +.menu-icon { + font-size: 14px; + width: 20px; + text-align: center; +} + +/* ============================================================ + Empty Project State + ============================================================ */ + +.empty-project-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; +} + +.empty-project-state .empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.empty-project-state h3 { + color: #e0e0e0; + font-size: 18px; + font-weight: 600; + margin: 0 0 8px 0; +} + +.empty-project-state p { + color: #888; + font-size: 14px; + margin: 0 0 24px 0; +} + +/* ============================================================ + Responsive Design + ============================================================ */ + +@media (max-width: 768px) { + .project-tabs, + .session-tabs { + padding: 0 4px; + } + + .project-tab { + padding: 8px 10px; + font-size: 12px; + } + + .project-tab .tab-label { + max-width: 80px; + } + + .session-tab { + padding: 6px 10px; + font-size: 11px; + } + + .session-tab .tab-label { + max-width: 100px; + } + + .context-menu { + min-width: 160px; + } +} + +@media (max-width: 480px) { + .project-tab .tab-count { + display: none; + } + + .session-tab .tab-close { + opacity: 1; + } +} + +/* ============================================================ + Animations + ============================================================ */ + +@keyframes tabFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.project-tab, +.session-tab { + animation: tabFadeIn 0.15s ease-out; +} + +/* ============================================================ + Drag and Drop (Future Enhancement) + ============================================================ */ + +.project-tab.dragging, +.session-tab.dragging { + opacity: 0.5; +} + +.project-tab.drag-over, +.session-tab.drag-over { + background: rgba(74, 158, 255, 0.1); + border-bottom-color: #4a9eff; +} + +/* ============================================================ + Tab Close Button Animation + ============================================================ */ + +.tab-close { + transform-origin: center; + transition: transform 0.2s ease; +} + +.tab-close:hover { + transform: rotate(90deg); +} + +/* ============================================================ + Scroll Buttons for Overflow (Optional Enhancement) + ============================================================ */ + +.tabs-scroll-button { + display: none; /* Show only when needed via JS */ + width: 32px; + height: 100%; + background: #1a1a1a; + border: none; + color: #888; + font-size: 18px; + cursor: pointer; + flex-shrink: 0; +} + +.tabs-scroll-button:hover { + background: #252525; + color: #e0e0e0; +} + +.tabs-scroll-button:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/public/claude-ide/session-tabs.js b/public/claude-ide/session-tabs.js new file mode 100644 index 00000000..6f7e5dac --- /dev/null +++ b/public/claude-ide/session-tabs.js @@ -0,0 +1,427 @@ +/** + * 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');