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:
@@ -233,7 +233,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
const EXPECTED_JS_VERSION = '1769083100000'; // Cache bust for tool rendering system
|
const EXPECTED_JS_VERSION = '1769083200000'; // Cache bust for CodeNomad-inspired two-level tabs
|
||||||
const CACHE_BUST_KEY = '_claude_cache_bust';
|
const CACHE_BUST_KEY = '_claude_cache_bust';
|
||||||
|
|
||||||
// Check if we need to force reload
|
// Check if we need to force reload
|
||||||
@@ -301,6 +301,7 @@
|
|||||||
<link rel="stylesheet" href="/claude/claude-ide/components/session-picker.css?v=1769027229">
|
<link rel="stylesheet" href="/claude/claude-ide/components/session-picker.css?v=1769027229">
|
||||||
<link rel="stylesheet" href="/claude/claude-ide/components/approval-card.css?v=1769027229">
|
<link rel="stylesheet" href="/claude/claude-ide/components/approval-card.css?v=1769027229">
|
||||||
<link rel="stylesheet" href="/claude/claude-ide/tool-rendering.css?v1769083100000">
|
<link rel="stylesheet" href="/claude/claude-ide/tool-rendering.css?v1769083100000">
|
||||||
|
<link rel="stylesheet" href="/claude/claude-ide/project-tabs.css?v1769083200000">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||||
|
|
||||||
<!-- Monaco Editor (VS Code Editor) - AMD Loader -->
|
<!-- Monaco Editor (VS Code Editor) - AMD Loader -->
|
||||||
@@ -411,6 +412,16 @@
|
|||||||
|
|
||||||
<!-- Chat View -->
|
<!-- Chat View -->
|
||||||
<div id="chat-view" class="view">
|
<div id="chat-view" class="view">
|
||||||
|
<!-- Two-Level Tab System (CodeNomad-inspired) -->
|
||||||
|
<div id="project-tabs">
|
||||||
|
<!-- Project tabs rendered by project-manager.js -->
|
||||||
|
<div class="loading">Loading projects...</div>
|
||||||
|
</div>
|
||||||
|
<div id="session-tabs">
|
||||||
|
<!-- Session tabs rendered by session-tabs.js -->
|
||||||
|
<div class="loading">Loading sessions...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
<div class="chat-sidebar-overlay" id="chat-sidebar-overlay"></div>
|
<div class="chat-sidebar-overlay" id="chat-sidebar-overlay"></div>
|
||||||
<div class="chat-sidebar" id="chat-sidebar">
|
<div class="chat-sidebar" id="chat-sidebar">
|
||||||
@@ -647,6 +658,8 @@
|
|||||||
<script src="/claude/claude-ide/preview-manager.js?v1769082165881"></script>
|
<script src="/claude/claude-ide/preview-manager.js?v1769082165881"></script>
|
||||||
<script src="/claude/claude-ide/chat-enhanced.js?v1769082165881"></script>
|
<script src="/claude/claude-ide/chat-enhanced.js?v1769082165881"></script>
|
||||||
<script src="/claude/claude-ide/tool-renderers.js?v1769083100000"></script>
|
<script src="/claude/claude-ide/tool-renderers.js?v1769083100000"></script>
|
||||||
|
<script src="/claude/claude-ide/project-manager.js?v1769083200000"></script>
|
||||||
|
<script src="/claude/claude-ide/session-tabs.js?v1769083200000"></script>
|
||||||
<script src="/claude/claude-ide/terminal.js?v1769082165881"></script>
|
<script src="/claude/claude-ide/terminal.js?v1769082165881"></script>
|
||||||
<script src="/claude/claude-ide/components/monaco-editor.js?v1769082165881"></script>
|
<script src="/claude/claude-ide/components/monaco-editor.js?v1769082165881"></script>
|
||||||
<script src="/claude/claude-ide/components/enhanced-chat-input.js?v1769082165881"></script>
|
<script src="/claude/claude-ide/components/enhanced-chat-input.js?v1769082165881"></script>
|
||||||
|
|||||||
458
public/claude-ide/project-manager.js
Normal file
458
public/claude-ide/project-manager.js
Normal file
@@ -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<projectId, Project>
|
||||||
|
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 = `
|
||||||
|
<div class="project-tabs-empty">
|
||||||
|
<span>No projects yet</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="project-tabs">
|
||||||
|
${projectsArray.map(project => this.renderProjectTab(project)).join('')}
|
||||||
|
<button class="project-tab project-tab-new" onclick="window.projectManager.createNewProject()">
|
||||||
|
<span class="tab-icon">+</span>
|
||||||
|
<span class="tab-label">New Project</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single project tab
|
||||||
|
*/
|
||||||
|
renderProjectTab(project) {
|
||||||
|
const isActive = project.id === this.activeProjectId;
|
||||||
|
const sessionCount = project.sessions.length;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button class="project-tab ${isActive ? 'active' : ''}"
|
||||||
|
data-project-id="${project.id}"
|
||||||
|
onclick="window.projectManager.switchProject('${project.id}')"
|
||||||
|
title="${escapeHtml(project.workingDir || 'Default project')}">
|
||||||
|
<span class="tab-icon">📁</span>
|
||||||
|
<span class="tab-label">${escapeHtml(project.name)}</span>
|
||||||
|
${sessionCount > 0 ? `<span class="tab-count">${sessionCount}</span>` : ''}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `
|
||||||
|
<div class="empty-project-state">
|
||||||
|
<div class="empty-icon">📁</div>
|
||||||
|
<h3>${escapeHtml(project.name)}</h3>
|
||||||
|
<p>No sessions yet in this project</p>
|
||||||
|
<button class="btn-primary" onclick="window.projectManager.createNewSessionInProject('${project.id}')">
|
||||||
|
Create Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
471
public/claude-ide/project-tabs.css
Normal file
471
public/claude-ide/project-tabs.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
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