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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user