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>
706 lines
23 KiB
JavaScript
706 lines
23 KiB
JavaScript
/**
|
||
* 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');
|