Add CodeNomad-inspired two-level tab system (Phase 2)
Phase 2 of enhancement plan: - Created project-manager.js for project-level organization - Created session-tabs.js for session-level organization - Created project-tabs.css with responsive design - Added tab structure to index.html - Cache bust: v1769083200000 Features: - Project tabs organize sessions by working directory - Session tabs show all sessions within active project - Context menu for rename/duplicate/delete/close - Visual indicators for active/running sessions - Responsive design for mobile Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
427
public/claude-ide/session-tabs.js
Normal file
427
public/claude-ide/session-tabs.js
Normal file
@@ -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 = `
|
||||
<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 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 = `
|
||||
<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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
Reference in New Issue
Block a user