Files
SuperCharged-Claude-Code-Up…/public/claude-ide/session-tabs.js
uroma b82837aa5f Fix tab persistence in Claude IDE - persist closed tabs to localStorage
Implement localStorage persistence for closed session and project tabs.
When users close tabs, they now remain closed after page refresh.

Changes:
- session-tabs.js: Add closedSessions tracking with localStorage
- project-manager.js: Add closedProjects tracking with localStorage
- Filter out closed tabs on load
- Persist state whenever tabs are closed

Fixes issue where closed tabs would reappear on page refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:40:31 +00:00

706 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<div class="session-tabs-empty">
<span>No sessions</span>
</div>
`;
return;
}
container.innerHTML = `
<div class="session-tabs">
${this.sessions.map(session => this.renderSessionTab(session)).join('')}
<button class="session-tab session-tab-new" onclick="window.sessionTabs.createNewSession()">
<span class="tab-icon">+</span>
</button>
</div>
`;
}
/**
* 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 `
<button class="session-tab ${isActive ? 'active' : ''} ${isRunning ? 'running' : ''}"
data-session-id="${session.id}"
onclick="window.sessionTabs.switchSession('${session.id}')"
oncontextmenu="window.sessionTabs.showContextMenu(event, '${session.id}')"
title="${escapeHtml(sessionName)}\nLast activity: ${relativeTime}">
<span class="tab-icon">${isRunning ? '💬' : '💭'}</span>
<span class="tab-label">${escapeHtml(sessionName)}</span>
${isRunning ? '<span class="tab-indicator"></span>' : ''}
<span class="tab-close" onclick="event.stopPropagation(); window.sessionTabs.closeSession('${session.id}')">×</span>
</button>
`;
}
/**
* 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 = `
<div class="context-menu-item" data-action="rename">
<span class="menu-icon">✏️</span>
<span>Rename</span>
</div>
<div class="context-menu-item" data-action="duplicate">
<span class="menu-icon">📋</span>
<span>Duplicate</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete">
<span class="menu-icon">🗑️</span>
<span>Delete</span>
</div>
<div class="context-menu-item danger" data-action="close">
<span class="menu-icon">✕</span>
<span>Close</span>
</div>
`;
// 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<boolean>} - 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 = `
<div class="confirm-modal-header">
<h3 class="confirm-modal-title">${escapeHtml(title)}</h3>
</div>
<div class="confirm-modal-body">
<p class="confirm-modal-message">${message}</p>
</div>
<div class="confirm-modal-footer">
<button class="btn-confirm-cancel" id="confirm-modal-cancel">Cancel</button>
<button class="btn-confirm-ok" id="confirm-modal-ok">${escapeHtml(confirmText)}</button>
</div>
`;
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');