Files
SuperCharged-Claude-Code-Up…/public/claude-ide/sessions-landing.js
uroma 0e413d4309 feat: group sessions by project on landing page
- Add loadSessionsAndProjects() to fetch sessions and projects in parallel
- Store projects in window.projectsMap for quick lookup
- Group sessions by projectId, separating assigned and unassigned
- Render collapsible project sections with icon, name, and session count
- Add toggleProjectSection() to collapse/expand sections (▼/▶)
- Display project badges on sessions when assigned to a project
- Unassigned sessions shown in separate section at bottom

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 17:07:45 +00:00

792 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>';
try {
// Fetch both sessions and projects in parallel
const [sessionsRes, projectsRes] = await Promise.all([
fetch('/claude/api/claude/sessions'),
fetch('/api/projects')
]);
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 = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
}
}
/**
* Load all sessions and render in table (legacy function for backward compatibility)
*/
async function loadSessions() {
await loadSessionsAndProjects();
}
/**
* Render sessions grouped by project
*/
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';
// Group sessions by projectId
const grouped = {
byProject: new Map(), // projectId -> {project, sessions: []}
unassigned: [] // sessions without projectId
};
sessions.forEach(session => {
const projectId = session.metadata?.projectId;
if (projectId && window.projectsMap?.has(projectId)) {
const project = window.projectsMap.get(projectId);
if (!grouped.byProject.has(projectId)) {
grouped.byProject.set(projectId, {
project: project,
sessions: []
});
}
grouped.byProject.get(projectId).sessions.push(session);
} else {
grouped.unassigned.push(session);
}
});
// Sort sessions within each group by last activity
grouped.byProject.forEach(group => {
group.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;
});
});
grouped.unassigned.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 project sections
grouped.byProject.forEach((group, projectId) => {
const section = createProjectSection(group.project, group.sessions);
tbody.appendChild(section);
});
// Render unassigned sessions if any
if (grouped.unassigned.length > 0) {
const section = createUnassignedSection(grouped.unassigned);
tbody.appendChild(section);
}
}
/**
* 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 = `
<span class="project-section-toggle">▼</span>
<span class="project-section-icon">${escapeHtml(icon)}</span>
<span class="project-section-name">${escapeHtml(project.name)}</span>
<span class="project-section-count">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</span>
`;
// 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 = `
<span class="project-section-toggle">▼</span>
<span class="project-section-icon">📄</span>
<span class="project-section-name">Unassigned Sessions</span>
<span class="project-section-count">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</span>
`;
// 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 ?
`<span class="session-project-badge">${escapeHtml(window.projectsMap.get(projectId).name)}</span>` : '';
tr.innerHTML = `
<td data-label="Project">
<h3 class="project-name">${escapeHtml(projectName)}</h3>
${projectBadge}
</td>
<td data-label="Last Activity">
<span class="last-activity">${relativeTime}</span>
</td>
<td data-label="Status">
<span class="status-badge ${statusClass}">${status}</span>
</td>
<td data-label="Actions">
<button class="btn-continue">Continue</button>
<button class="btn-menu" aria-label="Menu">⋮</button>
</td>
`;
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);
});
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 = `
<button class="dropdown-menu-item" data-action="rename">✏️ Rename</button>
<button class="dropdown-menu-item" data-action="duplicate">📋 Duplicate</button>
<button class="dropdown-menu-item danger" data-action="delete">🗑️ Delete</button>
`;
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 = `
<div class="loading-spinner"></div>
<p class="loading-text">${escapeHtml(message)}</p>
`;
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 = `
<span class="toast-icon">${getToastIcon(type)}</span>
<span class="toast-message">${escapeHtml(message)}</span>
`;
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;
}