diff --git a/database.sqlite b/database.sqlite new file mode 100644 index 00000000..8b63d091 Binary files /dev/null and b/database.sqlite differ diff --git a/database.sqlite-shm b/database.sqlite-shm new file mode 100644 index 00000000..f8c74514 Binary files /dev/null and b/database.sqlite-shm differ diff --git a/database.sqlite-wal b/database.sqlite-wal new file mode 100644 index 00000000..c8f2aa4f Binary files /dev/null and b/database.sqlite-wal differ diff --git a/docs/plans/2025-01-19-project-session-organization.md b/docs/plans/2025-01-19-project-session-organization.md new file mode 100644 index 00000000..dfaee3c1 --- /dev/null +++ b/docs/plans/2025-01-19-project-session-organization.md @@ -0,0 +1,2232 @@ +# Project and Session Organization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement persistent projects as first-class entities containing multiple sessions, with intelligent auto-assignment, manual reassignment via context menu, and soft-delete recycle bin. + +**Architecture:** Introduce a `projects` collection in MongoDB that stores project metadata (name, description, icon, color, path, session IDs). Sessions reference projects via `projectId`. Soft delete implemented with `deletedAt` timestamp. API endpoints provide CRUD operations, smart suggestions based on directory/recency/name matching. Frontend adds dedicated projects page, context menu for session reassignment, and recycle bin interface. + +**Tech Stack:** MongoDB (database), Express.js (API), Vanilla JavaScript (frontend), CSS (styling), xterm.js (existing terminal) + +--- + +## Task 1: Database Setup - Create Projects Collection + +**Files:** +- Modify: `server.js:39-45` (after claudeService initialization) + +**Step 1: Add projects collection initialization** + +Find the section where collections are created (around line 39-45). Add projects collection initialization. + +```javascript +// Initialize collections +const db = client.db(); +const sessionsCollection = db.collection('sessions'); +const projectsCollection = db.collection('projects'); // ADD THIS LINE + +// Create indexes for better query performance +await projectsCollection.createIndex({ deletedAt: 1 }); +await projectsCollection.createIndex({ name: 1 }); +``` + +**Step 2: Commit** + +```bash +git add server.js +git commit -m "feat: add projects collection initialization and indexes" +``` + +--- + +## Task 2: Create Project API Endpoints + +**Files:** +- Modify: `server.js` (add after sessions routes, around line 900) + +**Step 1: Add GET /api/projects endpoint** + +```javascript +// GET /api/projects - List all active projects +app.get('/api/projects', requireAuth, async (req, res) => { + try { + const projects = await projectsCollection.find({ + deletedAt: null + }).sort({ lastActivity: -1 }).toArray(); + + res.json({ + success: true, + projects: projects.map(p => ({ + id: p._id, + name: p.name, + description: p.description, + icon: p.icon, + color: p.color, + path: p.path, + sessionCount: p.sessionIds?.length || 0, + createdAt: p.createdAt, + lastActivity: p.lastActivity + })) + }); + } catch (error) { + console.error('Error fetching projects:', error); + res.status(500).json({ error: 'Failed to fetch projects' }); + } +}); +``` + +**Step 2: Add POST /api/projects endpoint** + +```javascript +// POST /api/projects - Create new project +app.post('/api/projects', requireAuth, async (req, res) => { + try { + const { name, path, description, icon, color } = req.body; + + // Validate required fields + if (!name || !path) { + return res.status(400).json({ error: 'Name and path are required' }); + } + + // Check if project with same name already exists + const existing = await projectsCollection.findOne({ + name, + deletedAt: null + }); + + if (existing) { + return res.status(409).json({ error: 'Project with this name already exists' }); + } + + const project = { + name, + path, + description: description || '', + icon: icon || 'šŸ“', + color: color || '#4a9eff', + sessionIds: [], + createdAt: new Date(), + lastActivity: new Date(), + deletedAt: null + }; + + const result = await projectsCollection.insertOne(project); + + res.status(201).json({ + success: true, + project: { + id: result.insertedId, + ...project + } + }); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ error: 'Failed to create project' }); + } +}); +``` + +**Step 3: Add PUT /api/projects/:id endpoint** + +```javascript +// PUT /api/projects/:id - Update project +app.put('/api/projects/:id', requireAuth, async (req, res) => { + try { + const { id } = req.params; + const { name, description, icon, color, path } = req.body; + + const update = {}; + if (name) update.name = name; + if (description !== undefined) update.description = description; + if (icon) update.icon = icon; + if (color) update.color = color; + if (path) update.path = path; + + const result = await projectsCollection.findOneAndUpdate( + { _id: new ObjectId(id), deletedAt: null }, + { $set: update }, + { returnDocument: 'after' } + ); + + if (!result) { + return res.status(404).json({ error: 'Project not found' }); + } + + res.json({ + success: true, + project: { + id: result._id, + name: result.name, + description: result.description, + icon: result.icon, + color: result.color, + path: result.path, + sessionCount: result.sessionIds?.length || 0, + createdAt: result.createdAt, + lastActivity: result.lastActivity + } + }); + } catch (error) { + console.error('Error updating project:', error); + res.status(500).json({ error: 'Failed to update project' }); + } +}); +``` + +**Step 4: Commit** + +```bash +git add server.js +git commit -m "feat: add project CRUD API endpoints" +``` + +--- + +## Task 3: Soft Delete and Recycle Bin Endpoints + +**Files:** +- Modify: `server.js` (after project CRUD endpoints) + +**Step 1: Add DELETE /api/projects/:id (soft delete)** + +```javascript +// DELETE /api/projects/:id - Soft delete project +app.delete('/api/projects/:id', requireAuth, async (req, res) => { + try { + const { id } = req.params; + + // Get project to soft delete its sessions too + const project = await projectsCollection.findOne({ + _id: new ObjectId(id), + deletedAt: null + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Soft delete project + await projectsCollection.updateOne( + { _id: new ObjectId(id) }, + { $set: { deletedAt: new Date() } } + ); + + // Soft delete all sessions in this project + await sessionsCollection.updateMany( + { projectId: new ObjectId(id) }, + { $set: { deletedAt: new Date() } } + ); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting project:', error); + res.status(500).json({ error: 'Failed to delete project' }); + } +}); +``` + +**Step 2: Add POST /api/projects/:id/restore** + +```javascript +// POST /api/projects/:id/restore - Restore from recycle bin +app.post('/api/projects/:id/restore', requireAuth, async (req, res) => { + try { + const { id } = req.params; + + // Restore project + const project = await projectsCollection.findOneAndUpdate( + { _id: new ObjectId(id), deletedAt: { $ne: null } }, + { $set: { deletedAt: null } }, + { returnDocument: 'after' } + ); + + if (!project) { + return res.status(404).json({ error: 'Project not found in recycle bin' }); + } + + // Restore all sessions in this project + await sessionsCollection.updateMany( + { projectId: new ObjectId(id) }, + { $set: { deletedAt: null } } + ); + + res.json({ success: true }); + } catch (error) { + console.error('Error restoring project:', error); + res.status(500).json({ error: 'Failed to restore project' }); + } +}); +``` + +**Step 3: Add DELETE /api/projects/:id/permanent** + +```javascript +// DELETE /api/projects/:id/permanent - Permanent delete +app.delete('/api/projects/:id/permanent', requireAuth, async (req, res) => { + try { + const { id } = req.params; + + // Permanently delete sessions + await sessionsCollection.deleteMany({ + projectId: new ObjectId(id) + }); + + // Permanently delete project + await projectsCollection.deleteOne({ + _id: new ObjectId(id) + }); + + res.json({ success: true }); + } catch (error) { + console.error('Error permanently deleting project:', error); + res.status(500).json({ error: 'Failed to permanently delete project' }); + } +}); +``` + +**Step 4: Add GET /api/recycle-bin** + +```javascript +// GET /api/recycle-bin - List deleted items +app.get('/api/recycle-bin', requireAuth, async (req, res) => { + try { + const deletedProjects = await projectsCollection.find({ + deletedAt: { $ne: null } + }).sort({ deletedAt: -1 }).toArray(); + + const result = await Promise.all(deletedProjects.map(async (project) => { + // Get session count for this project + const sessionCount = await sessionsCollection.countDocuments({ + projectId: project._id, + deletedAt: { $ne: null } + }); + + return { + id: project._id, + name: project.name, + description: project.description, + icon: project.icon, + path: project.path, + sessionCount, + deletedAt: project.deletedAt + }; + })); + + res.json({ + success: true, + items: result + }); + } catch (error) { + console.error('Error fetching recycle bin:', error); + res.status(500).json({ error: 'Failed to fetch recycle bin' }); + } +}); +``` + +**Step 5: Commit** + +```bash +git add server.js +git commit -m "feat: add soft delete, restore, permanent delete, and recycle bin endpoints" +``` + +--- + +## Task 4: Session Reassignment Endpoint + +**Files:** +- Modify: `server.js` (after recycle bin endpoint) + +**Step 1: Add POST /api/sessions/:id/move endpoint** + +```javascript +// POST /api/sessions/:id/move - Move session to different project +app.post('/api/sessions/:id/move', requireAuth, async (req, res) => { + try { + const { id } = req.params; + const { projectId } = req.body; // null for unassigned + + // Verify session exists + const session = await sessionsCollection.findOne({ + _id: new ObjectId(id), + deletedAt: null + }); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // If moving to a project, verify it exists and is not deleted + if (projectId) { + const project = await projectsCollection.findOne({ + _id: new ObjectId(projectId), + deletedAt: null + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Remove session from old project's sessionIds + if (session.projectId) { + await projectsCollection.updateOne( + { _id: session.projectId }, + { $pull: { sessionIds: new ObjectId(id) } } + ); + } + + // Add session to new project's sessionIds + await projectsCollection.updateOne( + { _id: new ObjectId(projectId) }, + { $push: { sessionIds: new ObjectId(id) } } + ); + } else { + // Moving to unassigned - remove from old project + if (session.projectId) { + await projectsCollection.updateOne( + { _id: session.projectId }, + { $pull: { sessionIds: new ObjectId(id) } } + ); + } + } + + // Update session's projectId + await sessionsCollection.updateOne( + { _id: new ObjectId(id) }, + { $set: { projectId: projectId ? new ObjectId(projectId) : null } } + ); + + res.json({ success: true }); + } catch (error) { + console.error('Error moving session:', error); + res.status(500).json({ error: 'Failed to move session' }); + } +}); +``` + +**Step 2: Commit** + +```bash +git add server.js +git commit -m "feat: add session move endpoint" +``` + +--- + +## Task 5: Smart Suggestions Endpoint + +**Files:** +- Modify: `server.js` (after session move endpoint) + +**Step 1: Add GET /api/projects/suggestions endpoint** + +```javascript +// GET /api/projects/suggestions?sessionId=xxx - Get project suggestions for a session +app.get('/api/projects/suggestions', requireAuth, async (req, res) => { + try { + const { sessionId } = req.query; + + if (!sessionId) { + return res.status(400).json({ error: 'sessionId is required' }); + } + + // Get session + const session = await sessionsCollection.findOne({ + _id: new ObjectId(sessionId), + deletedAt: null + }); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // Get all active projects + const projects = await projectsCollection.find({ + deletedAt: null + }).toArray(); + + // Calculate suggestions + const suggestions = []; + + for (const project of projects) { + let score = 0; + const reasons = []; + + // Directory matching (high weight) + if (session.workingDir === project.path) { + score += 90; + reasons.push('Same directory'); + } else if (session.workingDir?.startsWith(project.path)) { + score += 50; + reasons.push('Subdirectory'); + } + + // Recency (medium weight) + const daysSinceActivity = (Date.now() - project.lastActivity) / (1000 * 60 * 60 * 24); + if (daysSinceActivity < 1) { + score += 20; + reasons.push('Used today'); + } else if (daysSinceActivity < 7) { + score += 10; + reasons.push(`Used ${Math.floor(daysSinceActivity)} days ago`); + } + + // Name similarity (low weight) + if (session.name?.includes(project.name) || project.name.includes(session.name)) { + score += 15; + reasons.push('Similar name'); + } + + if (score > 0) { + suggestions.push({ + id: project._id, + name: project.name, + icon: project.icon, + color: project.color, + score, + reasons + }); + } + } + + // Sort by score and take top 3 + const topSuggestions = suggestions + .sort((a, b) => b.score - a.score) + .slice(0, 3); + + // Get all projects for "show all" option + const allProjects = projects.map(p => ({ + id: p._id, + name: p.name, + icon: p.icon, + color: p.color + })).sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + success: true, + suggestions: topSuggestions, + allProjects + }); + } catch (error) { + console.error('Error getting suggestions:', error); + res.status(500).json({ error: 'Failed to get suggestions' }); + } +}); +``` + +**Step 2: Commit** + +```bash +git add server.js +git commit -m "feat: add smart project suggestions endpoint" +``` + +--- + +## Task 6: Projects Page HTML + +**Files:** +- Create: `public/projects.html` + +**Step 1: Create projects page structure** + +```html + + + + + + Projects - Claude Code Web Interface + + + + +
+ +
+
+

Projects

+ ← Back to Sessions +
+
+ + + +
+
+ + +
+ +
+ + + +
+ + + + + + + + + + + + + +``` + +**Step 2: Commit** + +```bash +git add public/projects.html +git commit -m "feat: add projects page HTML structure" +``` + +--- + +## Task 7: Projects Page CSS + +**Files:** +- Create: `public/claude-ide/projects.css` + +**Step 1: Create projects page styles** + +```css +/* Projects Page Layout */ +.projects-page { + background: var(--bg-primary); + min-height: 100vh; +} + +.projects-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +/* Header */ +.projects-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.header-left h1 { + margin: 0; + font-size: 2rem; +} + +.back-link { + color: var(--text-secondary); + text-decoration: none; + transition: color 0.2s; +} + +.back-link:hover { + color: var(--accent-color); +} + +.header-right { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.search-box input { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + width: 250px; +} + +/* Projects Grid */ +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +/* Project Card */ +.project-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.project-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-color: var(--accent-color); +} + +.project-card-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.project-icon { + font-size: 2rem; + line-height: 1; +} + +.project-info { + flex: 1; +} + +.project-name { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.25rem 0; + color: var(--text-primary); +} + +.project-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; + line-height: 1.4; +} + +.project-menu-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + opacity: 0; + transition: opacity 0.2s; +} + +.project-card:hover .project-menu-btn { + opacity: 1; +} + +.project-menu-btn:hover { + color: var(--text-primary); +} + +.project-meta { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.project-path { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + word-break: break-all; +} + +.project-stats { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.session-count { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.last-activity { + font-size: 0.75rem; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h2 { + margin-bottom: 0.5rem; +} + +.empty-state p { + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-primary); + border-radius: 12px; + padding: 2rem; + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-large { + max-width: 700px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.modal-header h2 { + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); +} + +.modal-close:hover { + color: var(--text-primary); +} + +/* Form */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 1rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background: var(--accent-color); + color: white; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--border-color); +} + +/* Context Menu */ +.context-menu { + position: fixed; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + z-index: 1001; + padding: 0.25rem 0; +} + +.context-menu-item { + padding: 0.5rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.context-menu-item:hover { + background: var(--bg-secondary); +} + +.context-menu-divider { + height: 1px; + background: var(--border-color); + margin: 0.25rem 0; +} + +/* Recycle Bin */ +.recycle-bin-items { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.recycle-bin-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + opacity: 0.7; +} + +.recycle-bin-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.recycle-bin-actions { + display: flex; + gap: 0.5rem; +} + +/* Buttons */ +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.btn-success { + background: #28a745; + color: white; +} + +.btn-danger { + background: #dc3545; + color: white; +} +``` + +**Step 2: Commit** + +```bash +git add public/claude-ide/projects.css +git commit -m "feat: add projects page styles" +``` + +--- + +## Task 8: Projects Page JavaScript + +**Files:** +- Create: `public/claude-ide/projects.js` + +**Step 1: Create projects page logic** + +```javascript +// State +let projects = []; +let currentEditingProject = null; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadProjects(); + setupEventListeners(); +}); + +// Load projects +async function loadProjects() { + try { + const response = await fetch('/api/projects'); + const data = await response.json(); + + if (data.success) { + projects = data.projects; + renderProjects(); + } + } catch (error) { + console.error('Error loading projects:', error); + showToast('Failed to load projects', 'error'); + } +} + +// Render projects +function renderProjects(filter = '') { + const grid = document.getElementById('projectsGrid'); + const emptyState = document.getElementById('emptyState'); + + const filteredProjects = projects.filter(p => + p.name.toLowerCase().includes(filter.toLowerCase()) || + p.description.toLowerCase().includes(filter.toLowerCase()) || + p.path.toLowerCase().includes(filter.toLowerCase()) + ); + + if (filteredProjects.length === 0) { + grid.style.display = 'none'; + emptyState.style.display = 'block'; + return; + } + + grid.style.display = 'grid'; + emptyState.style.display = 'none'; + + grid.innerHTML = filteredProjects.map(project => ` +
+
+
${project.icon}
+
+

${escapeHtml(project.name)}

+

${escapeHtml(project.description || 'No description')}

+
+ +
+
+
${escapeHtml(project.path)}
+
+
+ šŸ“„ + ${project.sessionCount} session${project.sessionCount !== 1 ? 's' : ''} +
+
${formatDate(project.lastActivity)}
+
+
+
+ `).join(''); +} + +// Setup event listeners +function setupEventListeners() { + // Search + document.getElementById('projectSearch').addEventListener('input', (e) => { + renderProjects(e.target.value); + }); + + // Create project + document.getElementById('createProjectBtn').addEventListener('click', () => { + openProjectModal(); + }); + + // Project form + document.getElementById('projectForm').addEventListener('submit', handleProjectSubmit); + + // Recycle bin + document.getElementById('recycleBinBtn').addEventListener('click', openRecycleBinModal); + + // Close context menu on click outside + document.addEventListener('click', () => { + hideContextMenu(); + }); +} + +// Open project +function openProject(projectId) { + // Navigate to sessions landing with project filter + window.location.href = `/claude/?project=${projectId}`; +} + +// Open project modal (create or edit) +function openProjectModal(project = null) { + currentEditingProject = project; + + const modal = document.getElementById('projectModal'); + const title = document.getElementById('modalTitle'); + const form = document.getElementById('projectForm'); + + if (project) { + title.textContent = 'Edit Project'; + document.getElementById('projectName').value = project.name; + document.getElementById('projectPath').value = project.path; + document.getElementById('projectDescription').value = project.description || ''; + document.getElementById('projectIcon').value = project.icon || 'šŸ“'; + document.getElementById('projectColor').value = project.color || '#4a9eff'; + } else { + title.textContent = 'Create New Project'; + form.reset(); + document.getElementById('projectIcon').value = 'šŸ“'; + document.getElementById('projectColor').value = '#4a9eff'; + } + + modal.style.display = 'flex'; +} + +// Close project modal +function closeProjectModal() { + document.getElementById('projectModal').style.display = 'none'; + currentEditingProject = null; +} + +// Handle project submit +async function handleProjectSubmit(e) { + e.preventDefault(); + + const data = { + name: document.getElementById('projectName').value, + path: document.getElementById('projectPath').value, + description: document.getElementById('projectDescription').value, + icon: document.getElementById('projectIcon').value || 'šŸ“', + color: document.getElementById('projectColor').value || '#4a9eff' + }; + + try { + const url = currentEditingProject + ? `/api/projects/${currentEditingProject.id}` + : '/api/projects'; + + const method = currentEditingProject ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showToast(currentEditingProject ? 'Project updated' : 'Project created', 'success'); + closeProjectModal(); + await loadProjects(); + } else { + showToast(result.error || 'Failed to save project', 'error'); + } + } catch (error) { + console.error('Error saving project:', error); + showToast('Failed to save project', 'error'); + } +} + +// Show project context menu +function showProjectMenu(projectId, event) { + const project = projects.find(p => p.id === projectId); + if (!project) return; + + const menu = document.getElementById('contextMenu'); + menu.innerHTML = ` +
+ āœļø Edit +
+
+ šŸ—‘ļø Move to Recycle Bin +
+ `; + + menu.style.left = event.pageX + 'px'; + menu.style.top = event.pageY + 'px'; + menu.style.display = 'block'; +} + +// Hide context menu +function hideContextMenu() { + document.getElementById('contextMenu').style.display = 'none'; +} + +// Open project modal by ID +function openProjectModalById(projectId) { + const project = projects.find(p => p.id === projectId); + if (project) { + openProjectModal(project); + } + hideContextMenu(); +} + +// Delete project +async function deleteProject(projectId) { + if (!confirm('Move this project and all its sessions to the recycle bin?')) { + return; + } + + try { + const response = await fetch(`/api/projects/${projectId}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + showToast('Project moved to recycle bin', 'success'); + await loadProjects(); + } else { + showToast(result.error || 'Failed to delete project', 'error'); + } + } catch (error) { + console.error('Error deleting project:', error); + showToast('Failed to delete project', 'error'); + } + + hideContextMenu(); +} + +// Recycle bin modal +async function openRecycleBinModal() { + const modal = document.getElementById('recycleBinModal'); + const container = document.getElementById('recycleBinItems'); + + try { + const response = await fetch('/api/recycle-bin'); + const data = await response.json(); + + if (data.success) { + if (data.items.length === 0) { + container.innerHTML = '

Recycle bin is empty šŸŽ‰

'; + } else { + container.innerHTML = data.items.map(item => ` +
+
+
+ ${item.icon} ${escapeHtml(item.name)} +
+ ${item.sessionCount} session${item.sessionCount !== 1 ? 's' : ''} • Deleted ${formatDate(item.deletedAt)} +
+
+
+ + +
+
+
+ `).join(''); + } + + modal.style.display = 'flex'; + } + } catch (error) { + console.error('Error loading recycle bin:', error); + showToast('Failed to load recycle bin', 'error'); + } +} + +// Close recycle bin modal +function closeRecycleBinModal() { + document.getElementById('recycleBinModal').style.display = 'none'; +} + +// Restore project +async function restoreProject(projectId) { + try { + const response = await fetch(`/api/projects/${projectId}/restore`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.success) { + showToast('Project restored', 'success'); + await loadProjects(); + await openRecycleBinModal(); // Refresh bin + } else { + showToast(result.error || 'Failed to restore project', 'error'); + } + } catch (error) { + console.error('Error restoring project:', error); + showToast('Failed to restore project', 'error'); + } +} + +// Permanent delete +async function permanentDeleteProject(projectId) { + if (!confirm('Permanently delete this project and all its sessions? This cannot be undone!')) { + return; + } + + try { + const response = await fetch(`/api/projects/${projectId}/permanent`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + showToast('Project permanently deleted', 'success'); + await openRecycleBinModal(); // Refresh bin + } else { + showToast(result.error || 'Failed to delete project', 'error'); + } + } catch (error) { + console.error('Error deleting project:', error); + showToast('Failed to delete project', 'error'); + } +} + +// Helper functions +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(dateString) { + const date = new Date(dateString); + 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(); +} + +function showToast(message, type = 'info') { + // Import from ide.js or implement locally + if (typeof window.showToast === 'function') { + window.showToast(message, type); + } else { + alert(message); + } +} +``` + +**Step 2: Commit** + +```bash +git add public/claude-ide/projects.js +git commit -m "feat: add projects page JavaScript functionality" +``` + +--- + +## Task 9: Update Sessions Landing to Show Projects + +**Files:** +- Modify: `public/claude-ide/sessions-landing.js` + +**Step 1: Modify renderSessions to group by project** + +Find the `renderSessions` function and modify it to group sessions by project. This will require fetching projects first, then organizing sessions. + +Add this after the sessions are fetched: + +```javascript +// Add project fetching +async function loadSessionsAndProjects() { + const [sessionsRes, projectsRes] = await Promise.all([ + fetch('/claude/api/claude/sessions'), + fetch('/api/projects') + ]); + + const sessionsData = await sessionsRes.json(); + const projectsData = await projectsRes.json(); + + if (sessionsData.success) { + sessions = sessionsData.sessions; + } + + if (projectsData.success) { + const projects = projectsData.projects; + // Create a map for quick lookup + window.projectsMap = new Map(projects.map(p => [p.id.toString(), p])); + } + + renderSessions(); +} +``` + +Modify `renderSessions` to group by project: + +```javascript +function renderSessions() { + const container = document.getElementById('sessionsGrid'); + + // Group sessions by project + const grouped = { + unassigned: [], + byProject: {} + }; + + sessions.forEach(session => { + const projectId = session.projectId; + if (projectId && window.projectsMap?.has(projectId.toString())) { + if (!grouped.byProject[projectId]) { + grouped.byProject[projectId] = []; + } + grouped.byProject[projectId].push(session); + } else { + grouped.unassigned.push(session); + } + }); + + // Render projects first + let html = ''; + + for (const [projectId, projectSessions] of Object.entries(grouped.byProject)) { + const project = window.projectsMap.get(projectId); + if (!project) continue; + + html += ` +
+
+ ${project.icon} + ${escapeHtml(project.name)} + ${projectSessions.length} + ā–¼ +
+
+ ${projectSessions.map(session => renderSessionCard(session)).join('')} +
+
+ `; + } + + // Render unassigned sessions + if (grouped.unassigned.length > 0) { + html += ` +
+
+ šŸ“„ + Unassigned Sessions + ${grouped.unassigned.length} + ā–¼ +
+
+ ${grouped.unassigned.map(session => renderSessionCard(session)).join('')} +
+
+ `; + } + + container.innerHTML = html; +} + +function toggleProjectSection(projectId) { + const section = document.getElementById(`project-${projectId}`); + const icon = section.previousElementSibling.querySelector('.toggle-icon'); + + if (section.style.display === 'none') { + section.style.display = 'block'; + icon.textContent = 'ā–¼'; + } else { + section.style.display = 'none'; + icon.textContent = 'ā–¶'; + } +} +``` + +**Step 2: Add context menu to session cards** + +Modify `renderSessionCard` to add the context menu trigger: + +```javascript +function renderSessionCard(session) { + const project = session.projectId ? window.projectsMap?.get(session.projectId.toString()) : null; + + return ` +
+ + ${project ? `
${project.icon} ${escapeHtml(project.name)}
` : ''} + +
+ `; +} +``` + +**Step 3: Commit** + +```bash +git add public/claude-ide/sessions-landing.js +git commit -m "feat: group sessions by project on landing page" +``` + +--- + +## Task 10: Session Context Menu for Reassignment + +**Files:** +- Modify: `public/claude-ide/sessions-landing.js` + +**Step 1: Add session context menu function** + +```javascript +let currentSessionId = null; + +async function showSessionContextMenu(event, sessionId) { + event.preventDefault(); + currentSessionId = sessionId; + + const menu = document.getElementById('sessionContextMenu'); + + // Fetch suggestions + const suggestionsRes = await fetch(`/api/projects/suggestions?sessionId=${sessionId}`); + const suggestionsData = await suggestionsRes.json(); + + let menuHtml = ` +
+ šŸ”— Open in IDE +
+
+
Move to Project
+ `; + + // Add suggestions + if (suggestionsData.success && suggestionsData.suggestions.length > 0) { + suggestionsData.suggestions.forEach(suggestion => { + const icon = getMatchIcon(suggestion.score); + const reasons = suggestion.reasons.join(', '); + menuHtml += ` +
+ ${icon} ${escapeHtml(suggestion.name)} +
${reasons}
+
+ `; + }); + + menuHtml += `
`; + } + + // Add "Show All Projects" option + menuHtml += ` +
+ šŸ“‚ Show All Projects... +
+
+
+ šŸ“„ Move to Unassigned +
+ `; + + menu.innerHTML = menuHtml; + menu.style.left = event.pageX + 'px'; + menu.style.top = event.pageY + 'px'; + menu.style.display = 'block'; +} + +function getMatchIcon(score) { + if (score >= 90) return 'šŸŽÆ'; + if (score >= 50) return 'šŸ“‚'; + return 'šŸ’”'; +} + +async function moveSessionToProject(sessionId, projectId) { + try { + const response = await fetch(`/api/sessions/${sessionId}/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('Session moved', 'success'); + await loadSessionsAndProjects(); + } else { + showToast(result.error || 'Failed to move session', 'error'); + } + } catch (error) { + console.error('Error moving session:', error); + showToast('Failed to move session', 'error'); + } + + hideSessionContextMenu(); +} + +function showAllProjectsForMove(sessionId) { + // Could show a modal with all projects + // For now, just alert the user to use projects page + showToast('Use the Projects page to manage assignments', 'info'); + hideSessionContextMenu(); +} + +function hideSessionContextMenu() { + document.getElementById('sessionContextMenu').style.display = 'none'; + currentSessionId = null; +} + +// Close context menu on click outside +document.addEventListener('click', (e) => { + if (!e.target.closest('#sessionContextMenu')) { + hideSessionContextMenu(); + } +}); +``` + +**Step 2: Add context menu styles** + +```css +#sessionContextMenu { + position: fixed; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 250px; + z-index: 1000; + padding: 0.25rem 0; +} + +.context-menu-label { + padding: 0.25rem 1rem; + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + font-weight: 600; +} + +.suggestion-reason { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.125rem; +} + +.session-project-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: var(--accent-color); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; +} + +.project-section { + margin-bottom: 2rem; +} + +.project-section-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + margin-bottom: 1rem; + transition: background 0.2s; +} + +.project-section-header:hover { + background: var(--border-color); +} + +.project-section-header.unassigned { + border-style: dashed; +} + +.toggle-icon { + margin-left: auto; + transition: transform 0.2s; +} + +.project-sessions { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} +``` + +**Step 3: Commit** + +```bash +git add public/claude-ide/sessions-landing.js public/claude-ide/sessions-landing.css +git commit -m "feat: add session context menu for project reassignment" +``` + +--- + +## Task 11: Database Migration Script + +**Files:** +- Create: `scripts/migrate-to-projects.js` + +**Step 1: Create migration script** + +```javascript +const { MongoClient, ObjectId } = require('mongodb'); +const path = require('path'); + +// Configuration +const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017'; +const DB_NAME = process.env.DB_NAME || 'claude-web-interface'; + +async function migrate() { + const client = new MongoClient(MONGO_URI); + + try { + console.log('Connecting to MongoDB...'); + await client.connect(); + const db = client.db(DB_NAME); + + const sessionsCollection = db.collection('sessions'); + const projectsCollection = db.collection('projects'); + + console.log('Creating projects collection...'); + await db.createCollection('projects'); + + console.log('Creating indexes...'); + await projectsCollection.createIndex({ deletedAt: 1 }); + await projectsCollection.createIndex({ name: 1 }); + + console.log('Finding unique project names from existing sessions...'); + const uniqueProjects = await sessionsCollection.distinct('metadata.project'); + + console.log(`Found ${uniqueProjects.length} unique projects to migrate...`); + + const icons = ['šŸš€', 'šŸ“±', 'šŸŽØ', 'šŸ’»', 'šŸ”§', 'šŸ“Š', 'šŸŽÆ', 'šŸ”®', '⚔', '🌟']; + const colors = ['#4a9eff', '#ff6b6b', '#51cf66', '#ffd43b', '#cc5de8', '#ff922b', '#20c997', '#339af0']; + + let projectCount = 0; + + for (const name of uniqueProjects) { + if (!name) continue; + + console.log(`Migrating project: ${name}`); + + const projectSessions = await sessionsCollection.find({ + 'metadata.project': name + }).toArray(); + + if (projectSessions.length === 0) continue; + + const paths = [...new Set(projectSessions.map(s => s.workingDir).filter(Boolean))]; + const createdAt = projectSessions[0].createdAt || new Date(); + const lastActivity = Math.max(...projectSessions.map(s => s.lastActivity || s.createdAt)); + + const randomIcon = icons[Math.floor(Math.random() * icons.length)]; + const randomColor = colors[Math.floor(Math.random() * colors.length)]; + + const project = { + name, + description: `Migrated from existing sessions`, + icon: randomIcon, + color: randomColor, + path: paths[0] || '', + sessionIds: projectSessions.map(s => s._id), + createdAt, + lastActivity: new Date(lastActivity), + deletedAt: null + }; + + const result = await projectsCollection.insertOne(project); + console.log(` → Created project with ID: ${result.insertedId}`); + + // Update sessions with projectId + await sessionsCollection.updateMany( + { 'metadata.project': name }, + { $set: { projectId: result.insertedId } } + ); + + console.log(` → Updated ${projectSessions.length} sessions`); + projectCount++; + } + + console.log(`\nāœ… Migration complete! Created ${projectCount} projects.`); + + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } finally { + await client.close(); + } +} + +// Run migration +migrate(); +``` + +**Step 2: Add migration to package.json** + +Add to `scripts` section: + +```json +"scripts": { + "migrate:projects": "node scripts/migrate-to-projects.js" +} +``` + +**Step 3: Commit** + +```bash +git add scripts/migrate-to-projects.js package.json +git commit -m "feat: add database migration script for projects" +``` + +**Step 4: Run migration** + +```bash +npm run migrate:projects +``` + +**Step 5: Commit migration results** + +```bash +git add . +git commit -m "chore: run project migration" +``` + +--- + +## Task 12: Add Route Handler for Projects Page + +**Files:** +- Modify: `server.js` + +**Step 1: Add projects page route** + +Find the routes section (around line 70) and add: + +```javascript +// Projects page +app.get('/projects', (req, res) => { + if (req.session.userId) { + res.sendFile(path.join(__dirname, 'public', 'projects.html')); + } else { + res.redirect('/claude/'); + } +}); +``` + +**Step 2: Add link to projects page in navigation** + +Modify the landing page header to include a "Projects" link: + +```javascript +// In the landing page HTML or navigation component +// Add: Projects +``` + +**Step 3: Commit** + +```bash +git add server.js public/claude-landing.html +git commit -m "feat: add projects page route and navigation link" +``` + +--- + +## Task 13: Update Session Creation to Auto-Assign to Project + +**Files:** +- Modify: `public/claude-ide/ide.js` +- Modify: `server.js` + +**Step 1: Add projectId to session creation in IDE** + +Find the session creation code in ide.js and modify to include projectId if a project is selected: + +```javascript +// When creating a new session +async function createSession(sessionData) { + const currentProject = getCurrentProject(); // Get from project selector + const payload = { + ...sessionData, + projectId: currentProject?.id || null + }; + + const response = await fetch('/claude/api/claude/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + // ... handle response +} +``` + +**Step 2: Update server endpoint to handle projectId** + +Modify the session creation endpoint in server.js: + +```javascript +app.post('/claude/api/claude/sessions', requireAuth, async (req, res) => { + try { + const { name, workingDir, projectId } = req.body; + + // ... existing validation + + // If projectId provided, verify it exists + if (projectId) { + const project = await projectsCollection.findOne({ + _id: new ObjectId(projectId), + deletedAt: null + }); + + if (!project) { + return res.status(400).json({ error: 'Invalid project ID' }); + } + } + + const session = { + // ... existing fields + projectId: projectId ? new ObjectId(projectId) : null, + createdAt: new Date(), + lastActivity: new Date() + }; + + const result = await sessionsCollection.insertOne(session); + + // Add session to project's sessionIds + if (projectId) { + await projectsCollection.updateOne( + { _id: new ObjectId(projectId) }, + { + $push: { sessionIds: result.insertedId }, + $set: { lastActivity: new Date() } + } + ); + } + + // ... rest of endpoint + } catch (error) { + // ... error handling + } +}); +``` + +**Step 3: Commit** + +```bash +git add public/claude-ide/ide.js server.js +git commit -m "feat: auto-assign new sessions to selected project" +``` + +--- + +## Task 14: Testing and Documentation + +**Files:** +- Create: `docs/projects-feature.md` + +**Step 1: Create documentation** + +```markdown +# Projects Feature + +## Overview + +Projects are persistent containers that organize multiple Claude Code sessions. + +## Features + +### Creating Projects + +1. Navigate to `/projects` +2. Click "+ New Project" +3. Enter name and working directory (required) +4. Optionally add description, icon, and color +5. Click "Save Project" + +### Assigning Sessions to Projects + +**Automatic Assignment:** +- When creating a session from the IDE with a project selected, it auto-assigns + +**Manual Assignment:** +1. Go to sessions landing page (`/claude/`) +2. Right-click on any session card +3. Select "Move to Project" from context menu +4. Choose from: + - šŸŽÆ **Suggestions** - Smart recommendations based on directory, recency, name + - šŸ“‚ **All Projects** - Full project list + +### Managing Projects + +**Edit Project:** +- Right-click project card → Edit +- Modify any field except name +- Save changes + +**Delete Project:** +- Right-click project card → Move to Recycle Bin +- Project and all sessions are soft-deleted +- Can restore from recycle bin within 30 days + +**Recycle Bin:** +- Click "šŸ—‘ļø Recycle Bin" button on projects page +- Restore deleted projects (restores all sessions too) +- Permanently delete (cannot be undone) + +## API Reference + +### Projects + +- `GET /api/projects` - List all active projects +- `POST /api/projects` - Create new project +- `PUT /api/projects/:id` - Update project +- `DELETE /api/projects/:id` - Soft delete project +- `POST /api/projects/:id/restore` - Restore from recycle bin +- `DELETE /api/projects/:id/permanent` - Permanent delete + +### Sessions + +- `POST /api/sessions/:id/move` - Move session to different project +- `GET /api/projects/suggestions?sessionId=xxx` - Get project suggestions + +### Recycle Bin + +- `GET /api/recycle-bin` - List deleted items + +## Smart Suggestions + +The system suggests projects based on: + +1. **Directory Match (90 points)** - Exact path match +2. **Subdirectory (50 points)** - Session path is under project path +3. **Recent Use (20 points)** - Used today +4. **Week-old Use (10 points)** - Used within last week +5. **Name Similarity (15 points)** - Name overlap + +Suggestions with 90+ points show šŸŽÆ, 50-89 show šŸ“‚, lower show šŸ’” +``` + +**Step 2: Manual testing checklist** + +Test each feature and document results: + +```bash +# 1. Create project +curl -X POST http://localhost:3000/api/projects \ + -H "Content-Type: application/json" \ + -d '{"name":"Test Project","path":"/home/uroma/test"}' + +# 2. List projects +curl http://localhost:3000/api/projects + +# 3. Create session with projectId +curl -X POST http://localhost:3000/claude/api/claude/sessions \ + -H "Content-Type: application/json" \ + -d '{"name":"Test Session","workingDir":"/home/uroma/test","projectId":""}' + +# 4. Get suggestions +curl "http://localhost:3000/api/projects/suggestions?sessionId=" + +# 5. Move session +curl -X POST http://localhost:3000/api/sessions//move \ + -H "Content-Type: application/json" \ + -d '{"projectId":""}' + +# 6. Delete project +curl -X DELETE http://localhost:3000/api/projects/ + +# 7. Restore project +curl -X POST http://localhost:3000/api/projects//restore +``` + +**Step 3: Commit** + +```bash +git add docs/projects-feature.md +git commit -m "docs: add projects feature documentation" +``` + +--- + +## Task 15: Final Integration and Cleanup + +**Files:** +- Modify: `server.js` +- Modify: `public/claude-ide/ide.js` + +**Step 1: Update ObjectId import** + +Ensure ObjectId is imported at the top of server.js: + +```javascript +const { ObjectId } = require('mongodb'); +``` + +**Step 2: Add error handling for missing collections** + +Add safety check in case projects collection doesn't exist yet: + +```javascript +// At the start of API endpoints that use projectsCollection +if (!db.collection('projects')) { + await db.createCollection('projects'); +} +``` + +**Step 3: Ensure all async functions have proper error handling** + +Check all new endpoints have try-catch blocks with proper error responses. + +**Step 4: Restart server and test** + +```bash +# Stop existing server +pkill -f "node.*server.js" + +# Start new server +node server.js +``` + +**Step 5: Test the complete flow** + +1. Navigate to `/projects` +2. Create a new project +3. Go to `/claude/` +4. Create a session (verify it's unassigned) +5. Right-click session → move to project +6. Verify session appears under project +7. Delete project → verify in recycle bin +8. Restore project → verify it's back + +**Step 6: Final commit** + +```bash +git add . +git commit -m "feat: complete project and session organization feature + +- Persistent projects as first-class entities +- Smart session assignment with suggestions +- Context menu for session reassignment +- Soft delete with recycle bin +- Migration script for existing sessions +- Full API and UI implementation + +Closes #1" +``` + +**Step 7: Push to remote** + +```bash +git push origin feature/project-organization +``` + +--- + +## Completion Criteria + +- [x] Projects collection created with proper indexes +- [x] All CRUD endpoints working (create, read, update, delete) +- [x] Soft delete implemented (deletedAt timestamp) +- [x] Recycle bin UI functional +- [x] Session reassignment via context menu +- [x] Smart suggestions based on directory/recency/name +- [x] Auto-assignment when creating sessions in project context +- [x] Migration script successfully backfills existing data +- [x] All tests passing +- [x] Documentation complete + +**Ready for PR when all tasks complete!** diff --git a/server.js b/server.js index 4e6b2f8f..17212dd2 100644 --- a/server.js +++ b/server.js @@ -633,6 +633,74 @@ app.delete('/claude/api/claude/sessions/:id', requireAuth, (req, res) => { } }); +// Move session to different project +app.post('/api/sessions/:id/move', requireAuth, (req, res) => { + try { + const { id } = req.params; + const { projectId } = req.body; + + // Check if session exists (in-memory or historical) + let session = claudeService.sessions.get(id); + if (!session) { + // Check historical sessions + const historicalSessions = claudeService.loadHistoricalSessions(); + session = historicalSessions.find(s => s.id === id); + } + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // If projectId is provided, validate it exists and is not deleted + if (projectId !== null && projectId !== undefined) { + const validatedId = validateProjectId(projectId); + if (!validatedId) { + return res.status(400).json({ error: 'Invalid project ID' }); + } + + const project = db.prepare(` + SELECT id FROM projects + WHERE id = ? AND deletedAt IS NULL + `).get(validatedId); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Update session's metadata with projectId + if (claudeService.sessions.get(id)) { + // Active session - update metadata + const activeSession = claudeService.sessions.get(id); + activeSession.metadata.projectId = validatedId; + } else { + // Historical session - update in database + db.prepare(` + INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) + VALUES (?, ?, NULL) + `).run(id, validatedId); + } + } else { + // Move to unassigned (projectId = null) + if (claudeService.sessions.get(id)) { + // Active session - remove projectId from metadata + const activeSession = claudeService.sessions.get(id); + delete activeSession.metadata.projectId; + } else { + // Historical session - update in database + db.prepare(` + INSERT OR REPLACE INTO sessions (id, projectId, deletedAt) + VALUES (?, NULL, NULL) + `).run(id); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Error moving session:', error); + res.status(500).json({ error: 'Failed to move session' }); + } +}); + // Preview Management - Start preview server app.post('/claude/api/claude/sessions/:id/preview/start', requireAuth, async (req, res) => { try { @@ -1006,6 +1074,21 @@ app.delete('/api/projects/:id', requireAuth, (req, res) => { WHERE id = ? `).run(now, validatedId); + // Soft delete all sessions associated with this project + db.prepare(` + UPDATE sessions + SET deletedAt = ? + WHERE projectId = ? + `).run(now, validatedId); + + // Also update in-memory sessions + for (const [sessionId, session] of claudeService.sessions.entries()) { + if (session.metadata.projectId === validatedId) { + // Mark as deleted in metadata + session.metadata.deletedAt = now; + } + } + res.json({ success: true }); } catch (error) { console.error('Error soft deleting project:', error); diff --git a/services/database.js b/services/database.js index df00c094..ceb7f0f6 100644 --- a/services/database.js +++ b/services/database.js @@ -48,6 +48,26 @@ function initializeDatabase() { CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name) `); + // Create sessions table + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + projectId INTEGER NULL, + deletedAt TEXT NULL, + FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE SET NULL + ) + `); + + // Create index on projectId for efficient session queries + db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_projectId ON sessions(projectId) + `); + + // Create index on deletedAt for efficient soft-delete queries + db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_deletedAt ON sessions(deletedAt) + `); + return db; }