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>
This commit is contained in:
uroma
2026-01-19 17:07:45 +00:00
Unverified
parent 155bfeefdf
commit 0e413d4309
2 changed files with 344 additions and 26 deletions

View File

@@ -473,3 +473,138 @@ body.sessions-page {
max-width: none; max-width: none;
} }
} }
/**
* Project Section Styles
*/
.project-section {
border-bottom: 1px solid #333;
}
.project-section:last-child {
border-bottom: none;
}
/* Project Section Header */
.project-section-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #151515;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.project-section-header:hover {
background: #1a1a1a;
}
.project-section-toggle {
font-size: 12px;
color: #888;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.project-section-icon {
font-size: 20px;
flex-shrink: 0;
}
.project-section-name {
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
flex: 1;
}
.project-section-count {
font-size: 13px;
color: #888;
font-weight: 500;
flex-shrink: 0;
}
/* Project Section Sessions Container */
.project-section-sessions {
display: block;
transition: all 0.3s ease;
}
.project-section.collapsed .project-section-sessions {
display: none;
}
/* Session Rows within Project Sections */
.session-row {
border-bottom: 1px solid #252525;
transition: background 0.2s ease;
cursor: pointer;
}
.session-row:last-child {
border-bottom: none;
}
.session-row:hover {
background: #252525;
}
/* Session Project Badge */
.session-project-badge {
display: inline-block;
margin-top: 4px;
padding: 3px 8px;
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Unassigned Section Special Styling */
.unassigned-section .project-section-header {
background: #1a1a1a;
border-top: 1px solid #333;
}
.unassigned-section .project-section-header:hover {
background: #222;
}
/* Collapsed state */
.project-section.collapsed .project-section-toggle {
transform: rotate(-90deg);
}
/* Responsive Design for Project Sections */
@media (max-width: 768px) {
.project-section-header {
padding: 14px 16px;
}
.project-section-toggle {
font-size: 11px;
}
.project-section-icon {
font-size: 18px;
}
.project-section-name {
font-size: 14px;
}
.project-section-count {
font-size: 12px;
}
.session-project-badge {
font-size: 10px;
padding: 2px 6px;
}
}

View File

@@ -6,7 +6,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
checkAuth(); checkAuth();
initializeProjectInput(); initializeProjectInput();
loadSessions(); loadSessionsAndProjects();
}); });
// Check authentication // Check authentication
@@ -100,9 +100,9 @@ async function createProject(projectName) {
} }
/** /**
* Load all sessions and render in table * Load all sessions and projects, then render grouped by project
*/ */
async function loadSessions() { async function loadSessionsAndProjects() {
const tbody = document.getElementById('projects-tbody'); const tbody = document.getElementById('projects-tbody');
const emptyState = document.getElementById('projects-empty'); const emptyState = document.getElementById('projects-empty');
const table = document.getElementById('projects-table'); const table = document.getElementById('projects-table');
@@ -112,42 +112,54 @@ async function loadSessions() {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>';
try { try {
const res = await fetch('/claude/api/claude/sessions'); // Fetch both sessions and projects in parallel
const [sessionsRes, projectsRes] = await Promise.all([
fetch('/claude/api/claude/sessions'),
fetch('/api/projects')
]);
if (!res.ok) throw new Error('Request failed'); if (!sessionsRes.ok) throw new Error('Failed to load sessions');
if (!projectsRes.ok) throw new Error('Failed to load projects');
const data = await res.json(); const sessionsData = await sessionsRes.json();
const projectsData = await projectsRes.json();
// Combine active and historical sessions
const allSessions = [ const allSessions = [
...(data.active || []).map(s => ({...s, type: 'active'})), ...(sessionsData.active || []).map(s => ({...s, type: 'active'})),
...(data.historical || []).map(s => ({...s, type: 'historical'})) ...(sessionsData.historical || []).map(s => ({...s, type: 'historical'}))
]; ];
renderProjectsTable(allSessions); // Store projects in global map for quick lookup
window.projectsMap = new Map(
(projectsData.projects || []).map(p => [p.id, p])
);
renderSessionsGroupedByProject(allSessions);
} catch (error) { } catch (error) {
console.error('Error loading sessions:', 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>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
} }
} }
/** /**
* Render projects table with session data * Load all sessions and render in table (legacy function for backward compatibility)
*/ */
function renderProjectsTable(sessions) { async function loadSessions() {
await loadSessionsAndProjects();
}
/**
* Render sessions grouped by project
*/
function renderSessionsGroupedByProject(sessions) {
const tbody = document.getElementById('projects-tbody'); const tbody = document.getElementById('projects-tbody');
const emptyState = document.getElementById('projects-empty'); const emptyState = document.getElementById('projects-empty');
const table = document.getElementById('projects-table'); const table = document.getElementById('projects-table');
if (!tbody) return; if (!tbody) return;
// 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;
});
tbody.innerHTML = ''; tbody.innerHTML = '';
if (sessions.length === 0) { if (sessions.length === 0) {
@@ -159,17 +171,148 @@ function renderProjectsTable(sessions) {
if (table) table.style.display = 'table'; if (table) table.style.display = 'table';
if (emptyState) emptyState.style.display = 'none'; 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 => { sessions.forEach(session => {
const row = createProjectRow(session); const projectId = session.metadata?.projectId;
tbody.appendChild(row);
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);
}
} }
/** /**
* Create a single project row for the table * Render projects table with session data (legacy function for backward compatibility)
*/ */
function createProjectRow(session) { 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'); const tr = document.createElement('tr');
tr.className = 'session-row';
tr.dataset.sessionId = session.id; tr.dataset.sessionId = session.id;
const projectName = getProjectName(session); const projectName = getProjectName(session);
@@ -177,9 +320,16 @@ function createProjectRow(session) {
const status = getStatus(session); const status = getStatus(session);
const statusClass = getStatusClass(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 = ` tr.innerHTML = `
<td data-label="Project"> <td data-label="Project">
<h3 class="project-name">${escapeHtml(projectName)}</h3> <h3 class="project-name">${escapeHtml(projectName)}</h3>
${projectBadge}
</td> </td>
<td data-label="Last Activity"> <td data-label="Last Activity">
<span class="last-activity">${relativeTime}</span> <span class="last-activity">${relativeTime}</span>
@@ -212,6 +362,39 @@ function createProjectRow(session) {
return tr; 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 * Extract project name from session metadata
*/ */
@@ -295,13 +478,13 @@ function showProjectMenu(event, session) {
menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => { menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => {
closeProjectMenu(); closeProjectMenu();
await duplicateProject(session); await duplicateProject(session);
loadSessions(); loadSessionsAndProjects();
}); });
menu.querySelector('[data-action="delete"]').addEventListener('click', async () => { menu.querySelector('[data-action="delete"]').addEventListener('click', async () => {
closeProjectMenu(); closeProjectMenu();
await deleteProject(session); await deleteProject(session);
loadSessions(); loadSessionsAndProjects();
}); });
setTimeout(() => { setTimeout(() => {
@@ -494,7 +677,7 @@ async function deleteProject(session) {
// Refresh sessions // Refresh sessions
function refreshSessions() { function refreshSessions() {
loadSessions(); loadSessionsAndProjects();
} }
/** /**