/** * Sessions Landing Page JavaScript */ // Load sessions on page load document.addEventListener('DOMContentLoaded', () => { checkAuth(); initializeProjectInput(); loadSessionsAndProjects(); }); // Check authentication async function checkAuth() { try { const res = await fetch('/claude/api/auth/status'); if (!res.ok) { throw new Error('Request failed'); } const data = await res.json(); if (!data.authenticated) { // Redirect to login if not authenticated window.location.href = '/claude/login.html'; } } catch (error) { console.error('Auth check failed:', error); } } /** * Initialize project input field in hero section */ function initializeProjectInput() { const input = document.getElementById('project-input'); const status = document.getElementById('input-status'); if (!input) return; // Auto-focus on page load input.focus(); input.addEventListener('input', () => { const projectName = input.value.trim(); const hasInvalidChars = !validateProjectName(projectName); if (hasInvalidChars && projectName.length > 0) { status.textContent = 'Invalid characters'; status.classList.add('error'); } else { status.textContent = ''; status.classList.remove('error'); } }); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { const projectName = input.value.trim(); if (projectName && validateProjectName(projectName)) { createProject(projectName); } } }); } /** * Create a new project and navigate to IDE */ async function createProject(projectName) { try { showLoadingOverlay('Creating project...'); const res = await fetch('/claude/api/claude/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata: { type: 'chat', source: 'web-ide', project: projectName } }) }); if (!res.ok) throw new Error('Request failed'); const data = await res.json(); if (data.success) { // Minimum display time for smooth UX await new Promise(resolve => setTimeout(resolve, 300)); window.location.href = `/claude/ide?session=${data.session.id}`; } } catch (error) { console.error('Error creating project:', error); hideLoadingOverlay(); showToast('Failed to create project', 'error'); } } /** * Load all sessions and projects, then render grouped by project */ async function loadSessionsAndProjects() { const tbody = document.getElementById('projects-tbody'); const emptyState = document.getElementById('projects-empty'); const table = document.getElementById('projects-table'); if (!tbody) return; tbody.innerHTML = 'Loading...'; try { // Fetch both sessions and projects in parallel const [sessionsRes, projectsRes] = await Promise.all([ fetch('/claude/api/claude/sessions', { credentials: 'include' }), fetch('/api/projects', { credentials: 'include' }) ]); if (!sessionsRes.ok) throw new Error('Failed to load sessions'); if (!projectsRes.ok) throw new Error('Failed to load projects'); const sessionsData = await sessionsRes.json(); const projectsData = await projectsRes.json(); // Combine active and historical sessions const allSessions = [ ...(sessionsData.active || []).map(s => ({...s, type: 'active'})), ...(sessionsData.historical || []).map(s => ({...s, type: 'historical'})) ]; // Store projects in global map for quick lookup window.projectsMap = new Map( (projectsData.projects || []).map(p => [p.id, p]) ); renderSessionsGroupedByProject(allSessions); } catch (error) { console.error('Error loading sessions and projects:', error); tbody.innerHTML = 'Failed to load'; } } /** * Load all sessions and render in table (legacy function for backward compatibility) */ async function loadSessions() { await loadSessionsAndProjects(); } /** * Render sessions as a simple table */ function renderSessionsGroupedByProject(sessions) { const tbody = document.getElementById('projects-tbody'); const emptyState = document.getElementById('projects-empty'); const table = document.getElementById('projects-table'); if (!tbody) return; tbody.innerHTML = ''; if (sessions.length === 0) { if (table) table.style.display = 'none'; if (emptyState) emptyState.style.display = 'block'; return; } if (table) table.style.display = 'table'; if (emptyState) emptyState.style.display = 'none'; // Sort by last activity (newest first) sessions.sort((a, b) => { const dateA = new Date(a.lastActivity || a.createdAt || a.created_at); const dateB = new Date(b.lastActivity || b.createdAt || b.created_at); return dateB - dateA; }); // Render each session as a table row sessions.forEach(session => { const row = createSessionRow(session); tbody.appendChild(row); }); } /** * Render projects table with session data (legacy function for backward compatibility) */ function renderProjectsTable(sessions) { renderSessionsGroupedByProject(sessions); } /** * Create a collapsible project section */ function createProjectSection(project, sessions) { const container = document.createElement('div'); container.className = 'project-section'; container.dataset.projectId = project.id; // Create section header const header = document.createElement('div'); header.className = 'project-section-header'; header.onclick = () => toggleProjectSection(project.id); const icon = project.icon || '📁'; const sessionCount = sessions.length; header.innerHTML = ` ${escapeHtml(icon)} ${escapeHtml(project.name)} ${sessionCount} session${sessionCount !== 1 ? 's' : ''} `; // Create sessions container const sessionsContainer = document.createElement('div'); sessionsContainer.className = 'project-section-sessions'; // Render sessions within this project sessions.forEach(session => { const row = createSessionRow(session); sessionsContainer.appendChild(row); }); container.appendChild(header); container.appendChild(sessionsContainer); return container; } /** * Create unassigned sessions section */ function createUnassignedSection(sessions) { const container = document.createElement('div'); container.className = 'project-section unassigned-section'; // Create section header const header = document.createElement('div'); header.className = 'project-section-header'; header.onclick = () => toggleProjectSection('unassigned'); const sessionCount = sessions.length; header.innerHTML = ` 📄 Unassigned Sessions ${sessionCount} session${sessionCount !== 1 ? 's' : ''} `; // Create sessions container const sessionsContainer = document.createElement('div'); sessionsContainer.className = 'project-section-sessions'; // Render unassigned sessions sessions.forEach(session => { const row = createSessionRow(session); sessionsContainer.appendChild(row); }); container.appendChild(header); container.appendChild(sessionsContainer); return container; } /** * Create a single session row */ function createSessionRow(session) { const tr = document.createElement('tr'); tr.className = 'session-row'; tr.dataset.sessionId = session.id; const projectName = getProjectName(session); const relativeTime = getRelativeTime(session); const status = getStatus(session); const statusClass = getStatusClass(session); // Check if session has a project assigned const projectId = session.metadata?.projectId; const hasProject = projectId && window.projectsMap?.has(projectId); const projectBadge = hasProject ? `${escapeHtml(window.projectsMap.get(projectId).name)}` : ''; tr.innerHTML = `

${escapeHtml(projectName)}

${projectBadge} ${relativeTime} ${status} `; tr.addEventListener('click', (e) => { if (!e.target.closest('.btn-menu')) { continueToSession(session.id); } }); tr.querySelector('.btn-continue').addEventListener('click', (e) => { e.stopPropagation(); continueToSession(session.id); }); tr.querySelector('.btn-menu').addEventListener('click', (e) => { e.stopPropagation(); showProjectMenu(e, session); }); // Add right-click context menu tr.addEventListener('contextmenu', (e) => { e.preventDefault(); showSessionContextMenu(e, session.id); }); return tr; } /** * Toggle project section collapse/expand */ function toggleProjectSection(projectId) { const section = projectId === 'unassigned' ? document.querySelector('.unassigned-section') : document.querySelector(`.project-section[data-project-id="${projectId}"]`); if (!section) return; const toggle = section.querySelector('.project-section-toggle'); const sessionsContainer = section.querySelector('.project-section-sessions'); if (section.classList.contains('collapsed')) { // Expand section.classList.remove('collapsed'); toggle.textContent = '▼'; sessionsContainer.style.display = ''; } else { // Collapse section.classList.add('collapsed'); toggle.textContent = '▶'; sessionsContainer.style.display = 'none'; } } /** * Create a single project row for the table (legacy function for backward compatibility) */ function createProjectRow(session) { return createSessionRow(session); } /** * Extract project name from session metadata */ function getProjectName(session) { return session.metadata?.project || session.metadata?.projectName || session.workingDir?.split('/').pop() || 'Session ' + session.id.substring(0, 8); } /** * Get relative time string for session */ function getRelativeTime(session) { const date = new Date(session.lastActivity || session.createdAt || session.created_at); const now = new Date(); const diffMins = Math.floor((now - date) / 60000); const diffHours = Math.floor((now - date) / 3600000); const diffDays = Math.floor((now - date) / 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(); } /** * Get status text for session */ function getStatus(session) { if (session.status === 'running') return 'Active'; return 'Done'; } /** * Get status CSS class for session */ function getStatusClass(session) { if (session.status === 'running') return 'active'; return 'done'; } /** * Navigate to IDE with session */ async function continueToSession(sessionId) { showLoadingOverlay('Opening workspace...'); await new Promise(resolve => setTimeout(resolve, 300)); window.location.href = `/claude/ide?session=${sessionId}`; } /** * Show project menu dropdown */ function showProjectMenu(event, session) { closeProjectMenu(); const menu = document.createElement('div'); menu.className = 'dropdown-menu'; menu.id = 'project-menu'; menu.innerHTML = ` `; const rect = event.target.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 8}px`; menu.style.right = `${window.innerWidth - rect.right}px`; document.body.appendChild(menu); menu.querySelector('[data-action="rename"]').addEventListener('click', () => { closeProjectMenu(); renameProject(session); }); menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => { closeProjectMenu(); await duplicateProject(session); loadSessionsAndProjects(); }); menu.querySelector('[data-action="delete"]').addEventListener('click', async () => { closeProjectMenu(); await deleteProject(session); loadSessionsAndProjects(); }); setTimeout(() => { document.addEventListener('click', closeProjectMenu, { once: true }); }, 10); } /** * Close project menu dropdown */ function closeProjectMenu() { document.getElementById('project_menu')?.remove(); } /** * Rename project (inline edit) */ function renameProject(session) { const row = document.querySelector(`tr[data-session-id="${session.id}"]`); if (!row) return; const nameCell = row.querySelector('.project-name'); if (!nameCell) return; const currentName = nameCell.textContent; // Only block truly historical sessions (loaded from disk, not in memory) // Active and terminated sessions in memory can be renamed if (session.type === 'historical') { showToast('Cannot rename historical sessions', 'warning'); return; } // Create input element const input = document.createElement('input'); input.type = 'text'; input.className = 'project-name-input'; input.value = currentName; input.maxLength = 50; // Replace text with input nameCell.innerHTML = ''; nameCell.appendChild(input); input.focus(); input.select(); // Handle save const saveRename = async () => { const newName = input.value.trim(); // Validation if (!newName) { nameCell.textContent = currentName; showToast('Project name cannot be empty', 'error'); return; } if (!validateProjectName(newName)) { nameCell.textContent = currentName; showToast('Project name contains invalid characters', 'error'); return; } if (newName === currentName) { nameCell.textContent = currentName; return; } // Show saving state input.disabled = true; input.style.opacity = '0.6'; try { const res = await fetch(`/claude/api/claude/sessions/${session.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata: { ...session.metadata, project: newName } }) }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || 'Failed to rename'); } const data = await res.json(); if (data.success) { nameCell.textContent = newName; showToast('Project renamed successfully', 'success'); } else { throw new Error(data.error || 'Failed to rename'); } } catch (error) { console.error('Error renaming project:', error); nameCell.textContent = currentName; showToast(error.message || 'Failed to rename project', 'error'); } }; // Handle cancel const cancelRename = () => { nameCell.textContent = currentName; }; // Event listeners input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { input.blur(); // Trigger save on blur } else if (e.key === 'Escape') { cancelRename(); } }); input.addEventListener('blur', () => { // Small delay to allow Enter key to process first setTimeout(() => { if (document.activeElement !== input) { saveRename(); } }, 100); }); } /** * Duplicate project/session */ async function duplicateProject(session) { try { showLoadingOverlay('Duplicating project...'); const res = await fetch(`/claude/api/claude/sessions/${session.id}/duplicate`, { method: 'POST' }); if (!res.ok) throw new Error('Request failed'); const data = await res.json(); if (data.success) { hideLoadingOverlay(); showToast('Project duplicated successfully', 'success'); } else { throw new Error(data.error || 'Failed to duplicate'); } } catch (error) { console.error('Error duplicating project:', error); hideLoadingOverlay(); showToast('Failed to duplicate project', 'error'); } } /** * Delete project/session with confirmation */ async function deleteProject(session) { const projectName = getProjectName(session); if (!confirm(`Are you sure you want to delete "${projectName}"? This action cannot be undone.`)) { return; } try { showLoadingOverlay('Deleting project...'); const res = await fetch(`/claude/api/claude/sessions/${session.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Request failed'); const data = await res.json(); if (data.success) { hideLoadingOverlay(); showToast('Project deleted successfully', 'success'); } else { throw new Error(data.error || 'Failed to delete'); } } catch (error) { console.error('Error deleting project:', error); hideLoadingOverlay(); showToast('Failed to delete project', 'error'); } } // Refresh sessions function refreshSessions() { loadSessionsAndProjects(); } /** * Validate project name for invalid characters * @param {string} name - The project name to validate * @returns {boolean} - True if valid, false if contains invalid characters */ function validateProjectName(name) { const invalidChars = /[\/\\<>:"|?*]/; return !invalidChars.test(name); } /** * Show loading overlay * @param {string} message - Optional custom message (default: "Loading...") */ function showLoadingOverlay(message = 'Loading...') { let overlay = document.getElementById('loading-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'loading-overlay'; overlay.className = 'loading-overlay'; overlay.innerHTML = `

${escapeHtml(message)}

`; document.body.appendChild(overlay); } else { // Update message if provided const textElement = overlay.querySelector('.loading-text'); if (textElement) { textElement.textContent = message; } } overlay.classList.remove('hidden'); setTimeout(() => { overlay.classList.add('visible'); }, 10); } /** * Hide loading overlay */ function hideLoadingOverlay() { const overlay = document.getElementById('loading-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => { overlay.classList.add('hidden'); }, 300); } } /** * Show toast notification * @param {string} message - The message to display * @param {string} type - The type of toast: 'success', 'error', 'info' * @param {number} duration - Duration in milliseconds (default: 3000) */ function showToast(message, type = 'info', duration = 3000) { // Remove existing toasts const existingToasts = document.querySelectorAll('.toast-notification'); existingToasts.forEach(toast => toast.remove()); // Create toast element const toast = document.createElement('div'); toast.className = `toast-notification toast-${type}`; toast.innerHTML = ` ${getToastIcon(type)} ${escapeHtml(message)} `; document.body.appendChild(toast); // Trigger animation setTimeout(() => { toast.classList.add('visible'); }, 10); // Auto remove after duration setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => { toast.remove(); }, 300); }, duration); } /** * Get toast icon based on type */ function getToastIcon(type) { const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' }; return icons[type] || icons.info; } /** * Escape HTML to prevent XSS */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Show session context menu for project reassignment */ async function showSessionContextMenu(event, sessionId) { event.preventDefault(); hideSessionContextMenu(); // Create context menu element const menu = document.createElement('div'); menu.id = 'sessionContextMenu'; menu.className = 'session-context-menu'; // Fetch project suggestions let suggestions = []; try { const res = await fetch(`/api/projects/suggestions?sessionId=${sessionId}`, { credentials: 'include' }); if (res.ok) { const data = await res.json(); suggestions = data.suggestions || []; } } catch (error) { console.error('Error fetching suggestions:', error); } // Build menu HTML let menuHtml = `
🚀 Open in IDE
Move to Project
`; // Add top 3 suggestions const topSuggestions = suggestions.slice(0, 3); topSuggestions.forEach(suggestion => { const icon = getMatchIcon(suggestion.score); const project = window.projectsMap?.get(suggestion.projectId); if (project) { menuHtml += `
${icon}
${escapeHtml(project.name)} ${escapeHtml(suggestion.reason)}
${Math.round(suggestion.score)}%
`; } }); // Add "Show All Projects" option menuHtml += `
📂 Show All Projects...
📄 Move to Unassigned
`; menu.innerHTML = menuHtml; document.body.appendChild(menu); // Position menu at mouse coordinates const x = event.clientX; const y = event.clientY; // Ensure menu doesn't go off screen const menuRect = menu.getBoundingClientRect(); const maxX = window.innerWidth - menuRect.width - 10; const maxY = window.innerHeight - menuRect.height - 10; menu.style.left = `${Math.min(x, maxX)}px`; menu.style.top = `${Math.min(y, maxY)}px`; // Store session ID for event handlers menu.dataset.sessionId = sessionId; // Add event listeners menu.addEventListener('click', handleContextMenuClick); // Close menu when clicking outside setTimeout(() => { document.addEventListener('click', hideSessionContextMenu, { once: true }); }, 10); } /** * Handle context menu item clicks */ async function handleContextMenuClick(event) { const menu = document.getElementById('sessionContextMenu'); if (!menu) return; const target = event.target.closest('.context-menu-item'); if (!target) return; const action = target.dataset.action; const sessionId = menu.dataset.sessionId; hideSessionContextMenu(); switch (action) { case 'open': continueToSession(sessionId); break; case 'move': const projectId = target.dataset.projectId; await moveSessionToProject(sessionId, projectId); break; case 'show-all': showAllProjectsModal(sessionId); break; case 'unassigned': await moveSessionToProject(sessionId, null); break; } } /** * Get match icon based on score */ function getMatchIcon(score) { if (score >= 90) return '🎯'; if (score >= 50) return '📂'; return '💡'; } /** * Move session to project */ async function moveSessionToProject(sessionId, projectId) { try { showLoadingOverlay('Moving session...'); const res = await fetch(`/api/projects/sessions/${sessionId}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ projectId }) }); if (!res.ok) throw new Error('Request failed'); const data = await res.json(); if (data.success) { hideLoadingOverlay(); showToast('Session moved successfully', 'success'); await loadSessionsAndProjects(); } else { throw new Error(data.error || 'Failed to move session'); } } catch (error) { console.error('Error moving session:', error); hideLoadingOverlay(); showToast(error.message || 'Failed to move session', 'error'); } } /** * Show all projects modal */ function showAllProjectsModal(sessionId) { // Create modal overlay const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'allProjectsModal'; // Create modal content const modal = document.createElement('div'); modal.className = 'all-projects-modal'; // Build projects list const projects = Array.from(window.projectsMap?.values() || []); let projectsHtml = ''; if (projects.length === 0) { projectsHtml = '
No projects available
'; } else { projectsHtml = '
'; projects.forEach(project => { projectsHtml += `
${escapeHtml(project.icon || '📁')} ${escapeHtml(project.name)}
`; }); projectsHtml += '
'; } modal.innerHTML = ` `; overlay.appendChild(modal); document.body.appendChild(overlay); // Add click handlers modal.querySelectorAll('.project-option').forEach(option => { option.addEventListener('click', async () => { const projectId = option.dataset.projectId; closeAllProjectsModal(); await moveSessionToProject(sessionId, projectId); }); }); // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) { closeAllProjectsModal(); } }); // Trigger animation setTimeout(() => { overlay.classList.add('visible'); }, 10); } /** * Close all projects modal */ function closeAllProjectsModal() { const modal = document.getElementById('allProjectsModal'); if (modal) { modal.classList.remove('visible'); setTimeout(() => { modal.remove(); }, 300); } } /** * Hide session context menu */ function hideSessionContextMenu() { const menu = document.getElementById('sessionContextMenu'); if (menu) { menu.remove(); } }