/**
* 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;
this.closedSessions = new Set(); // Track closed session IDs
this.STORAGE_KEY = 'claude_ide_closed_sessions';
}
/**
* Initialize session tabs
*/
initialize() {
if (this.initialized) return;
console.log('[SessionTabs] Initializing...');
this.loadClosedSessions();
this.render();
this.initialized = true;
}
/**
* Load closed sessions from localStorage
*/
loadClosedSessions() {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const closedIds = JSON.parse(stored);
this.closedSessions = new Set(closedIds);
console.log('[SessionTabs] Loaded', this.closedSessions.size, 'closed sessions from storage');
}
} catch (error) {
console.error('[SessionTabs] Error loading closed sessions:', error);
this.closedSessions = new Set();
}
}
/**
* Save closed sessions to localStorage
*/
saveClosedSessions() {
try {
const closedIds = Array.from(this.closedSessions);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(closedIds));
console.log('[SessionTabs] Saved', closedIds.length, 'closed sessions to storage');
} catch (error) {
console.error('[SessionTabs] Error saving closed sessions:', error);
}
}
/**
* Set sessions for current project
*/
setSessions(sessions) {
// Filter out closed sessions
this.sessions = (sessions || []).filter(s => !this.closedSessions.has(s.id));
console.log('[SessionTabs] Set', this.sessions.length, 'sessions (filtered out', (sessions || []).length - this.sessions.length, 'closed)');
}
/**
* 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);
// Add to closed sessions set and persist
this.closedSessions.add(sessionId);
this.saveClosedSessions();
// 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 = `
`;
// 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 = `
`;
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');