diff --git a/server/index.js b/server/index.js index 61e80085..d89523e3 100755 --- a/server/index.js +++ b/server/index.js @@ -28,7 +28,17 @@ import { spawn } from 'child_process'; import pty from 'node-pty'; import mime from 'mime-types'; -import { getProjects, getSessions, renameProject, deleteSession, deleteProject, getProjectTaskMaster, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; +import { + getProjects, + getSessionsById, + renameProjectById, + deleteSessionById, + deleteProjectById, + getProjectTaskMasterById, + getProjectPathById, + clearProjectDirectoryCache, + searchConversations, +} from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; @@ -428,20 +438,25 @@ app.get('/api/projects', authenticateToken, async (req, res) => { } }); -app.get('/api/projects/:projectName/taskmaster', authenticateToken, async (req, res) => { +// Project-scoped TaskMaster details; identified by DB-assigned `projectId`. +app.get('/api/projects/:projectId/taskmaster', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; - const taskMasterDetails = await getProjectTaskMaster(projectName); + const { projectId } = req.params; + const taskMasterDetails = await getProjectTaskMasterById(projectId); + if (!taskMasterDetails) { + return res.status(404).json({ error: 'Project not found' }); + } res.json(taskMasterDetails); } catch (error) { res.status(500).json({ error: error.message }); } }); -app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => { +// Sessions for a project; `projectId` is resolved to a path via the DB. +app.get('/api/projects/:projectId/sessions', authenticateToken, async (req, res) => { try { const { limit = 5, offset = 0 } = req.query; - const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset)); + const result = await getSessionsById(req.params.projectId, parseInt(limit), parseInt(offset)); applyCustomSessionNames(result.sessions, 'claude'); res.json(result); } catch (error) { @@ -449,23 +464,23 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re } }); -// Rename project endpoint -app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => { +// Rename project endpoint; stores the custom name on the DB row for `projectId`. +app.put('/api/projects/:projectId/rename', authenticateToken, async (req, res) => { try { const { displayName } = req.body; - await renameProject(req.params.projectName, displayName); + await renameProjectById(req.params.projectId, displayName); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); -// Delete session endpoint -app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { +// Delete session endpoint; resolves `projectId` to path before touching disk. +app.delete('/api/projects/:projectId/sessions/:sessionId', authenticateToken, async (req, res) => { try { - const { projectName, sessionId } = req.params; - console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); - await deleteSession(projectName, sessionId); + const { projectId, sessionId } = req.params; + console.log(`[API] Deleting session: ${sessionId} from project: ${projectId}`); + await deleteSessionById(projectId, sessionId); sessionsDb.deleteName(sessionId, 'claude'); console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); @@ -504,12 +519,13 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) = // Delete project endpoint // force=true to allow removal even when sessions exist // deleteData=true to also delete session/memory files on disk (destructive) -app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => { +// `projectId` is resolved to an absolute path through the DB before cleanup. +app.delete('/api/projects/:projectId', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const force = req.query.force === 'true'; const deleteData = req.query.deleteData === 'true'; - await deleteProject(projectName, force, deleteData); + await deleteProjectById(projectId, force, deleteData); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); @@ -694,9 +710,9 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => { }); // Read file content endpoint -app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/file', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { filePath } = req.query; @@ -705,7 +721,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) = return res.status(400).json({ error: 'Invalid file path' }); } - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Resolve the absolute project root via the DB-backed helper; the + // caller passes the DB-assigned `projectId`, not a folder name. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -734,9 +752,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) = }); // Serve raw file bytes for previews and downloads. -app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/files/content', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { path: filePath } = req.query; @@ -745,7 +763,8 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re return res.status(400).json({ error: 'Invalid file path' }); } - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Projects are now addressed by DB `projectId`, resolved to their path here. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -791,9 +810,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re }); // Save file content endpoint -app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => { +app.put('/api/projects/:projectId/file', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { filePath, content } = req.body; @@ -806,7 +825,8 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) = return res.status(400).json({ error: 'Content is required' }); } - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Projects are now addressed by DB `projectId`, resolved to their path here. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -840,19 +860,16 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) = } }); -app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) => { try { // Using fsPromises from import - // Use extractProjectDirectory to get the actual project path - let actualPath; - try { - actualPath = await extractProjectDirectory(req.params.projectName); - } catch (error) { - console.error('Error extracting project directory:', error); - // Fallback to simple dash replacement - actualPath = req.params.projectName.replace(/-/g, '/'); + // Resolve the project's absolute path through the DB (projectId is the + // primary key of the `projects` table after the identifier migration). + const actualPath = await getProjectPathById(req.params.projectId); + if (!actualPath) { + return res.status(404).json({ error: 'Project not found' }); } // Check if path exists @@ -917,10 +934,10 @@ function validateFilename(name) { return { valid: true }; } -// POST /api/projects/:projectName/files/create - Create new file or directory -app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => { +// POST /api/projects/:projectId/files/create - Create new file or directory +app.post('/api/projects/:projectId/files/create', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { path: parentPath, type, name } = req.body; // Validate input @@ -937,8 +954,8 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re return res.status(400).json({ error: nameValidation.error }); } - // Get project root - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -994,10 +1011,10 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re } }); -// PUT /api/projects/:projectName/files/rename - Rename file or directory -app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => { +// PUT /api/projects/:projectId/files/rename - Rename file or directory +app.put('/api/projects/:projectId/files/rename', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { oldPath, newName } = req.body; // Validate input @@ -1010,8 +1027,8 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req return res.status(400).json({ error: nameValidation.error }); } - // Get project root - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -1071,10 +1088,10 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req } }); -// DELETE /api/projects/:projectName/files - Delete file or directory -app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => { +// DELETE /api/projects/:projectId/files - Delete file or directory +app.delete('/api/projects/:projectId/files', authenticateToken, async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { path: targetPath, type } = req.body; // Validate input @@ -1082,8 +1099,8 @@ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, re return res.status(400).json({ error: 'Path is required' }); } - // Get project root - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -1136,7 +1153,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, re } }); -// POST /api/projects/:projectName/files/upload - Upload files +// POST /api/projects/:projectId/files/upload - Upload files // Dynamic import of multer for file uploads const uploadFilesHandler = async (req, res) => { // Dynamic import of multer @@ -1175,7 +1192,7 @@ const uploadFilesHandler = async (req, res) => { } try { - const { projectName } = req.params; + const { projectId } = req.params; const { targetPath, relativePaths } = req.body; // Parse relative paths if provided (for folder uploads) @@ -1189,7 +1206,7 @@ const uploadFilesHandler = async (req, res) => { } console.log('[DEBUG] File upload request:', { - projectName, + projectId, targetPath: JSON.stringify(targetPath), targetPathType: typeof targetPath, filesCount: req.files?.length, @@ -1200,8 +1217,8 @@ const uploadFilesHandler = async (req, res) => { return res.status(400).json({ error: 'No files provided' }); } - // Get project root - const projectRoot = await extractProjectDirectory(projectName).catch(() => null); + // Resolve the project directory through the DB using the new projectId. + const projectRoot = await getProjectPathById(projectId); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } @@ -1298,7 +1315,7 @@ const uploadFilesHandler = async (req, res) => { }); }; -app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler); +app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler); /** * Proxy an authenticated client WebSocket to a plugin's internal WS server. @@ -1905,8 +1922,10 @@ function handleShellConnection(ws) { console.error('[ERROR] Shell WebSocket error:', error); }); } -// Image upload endpoint -app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => { +// Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name) +// but the current implementation doesn't need to touch the project directory, +// so we just leave the param rename for consistency with the rest of the API. +app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req, res) => { try { const multer = (await import('multer')).default; const path = (await import('path')).default; @@ -1990,10 +2009,11 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r } }); -// Get token usage for a specific session -app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { +// Get token usage for a specific session. `projectId` is the DB primary key; +// the Claude branch below resolves it to an absolute path via the DB. +app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { try { - const { projectName, sessionId } = req.params; + const { projectId, sessionId } = req.params; const { provider = 'claude' } = req.query; const homeDir = os.homedir(); @@ -2097,13 +2117,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica } // Handle Claude sessions (default) - // Extract actual project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { - console.error('Error extracting project directory:', error); - return res.status(500).json({ error: 'Failed to determine project path' }); + // Resolve the project path through the DB using the caller-supplied + // `projectId`. Legacy code here called extractProjectDirectory with a + // folder-encoded project name; the migration centralizes that lookup + // in the projects table. + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + return res.status(404).json({ error: 'Project not found' }); } // Construct the JSONL file path diff --git a/server/modules/database/repositories/projects.db.ts b/server/modules/database/repositories/projects.db.ts index d50e5700..54481716 100644 --- a/server/modules/database/repositories/projects.db.ts +++ b/server/modules/database/repositories/projects.db.ts @@ -47,6 +47,25 @@ export const projectsDb = { return row ?? null; }, + /** + * Resolve the absolute project directory from a database project_id. + * + * This is the canonical lookup used after the projectName → projectId migration: + * API routes receive the DB-assigned `projectId` and must resolve the real folder + * path through this helper before touching the filesystem. Returns `null` when the + * project row does not exist so callers can respond with a 404. + */ + getProjectPathById(projectId: string): string | null { + const db = getConnection(); + const row = db.prepare(` + SELECT project_path + FROM projects + WHERE project_id = ? + `).get(projectId) as Pick | undefined; + + return row?.project_path ?? null; + }, + getProjectPaths(): ProjectRow[] { const db = getConnection(); return db.prepare(` diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 7a2346e8..8e3571db 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -192,17 +192,29 @@ export const sessionsDb = { /** * Legacy-compatibility method kept for parity with `server/database/db.js`. + * + * Renaming a session is a metadata-only change — it's not actual activity, + * so existing rows intentionally keep their `updated_at` untouched. This + * prevents the sidebar's "last activity" timestamp from jumping around when + * a user simply edits a session's label. + * + * When the row doesn't exist yet we still have to seed `created_at`/ + * `updated_at`; we write ISO-8601 UTC (with the `Z` suffix) rather than + * rely on SQLite's `CURRENT_TIMESTAMP`, which stores a naive + * `"YYYY-MM-DD HH:MM:SS"` value that JavaScript's `new Date(...)` parses as + * local time and displays with the wrong offset. + * * TODO: Remove after all legacy imports are migrated to the new repository API. */ setName(sessionId: string, provider: string, customName: string): void { const db = getConnection(); + const nowIso = new Date().toISOString(); db.prepare( - `INSERT INTO sessions (session_id, provider, custom_name) - VALUES (?, ?, ?) + `INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(session_id, provider) DO UPDATE SET - custom_name = excluded.custom_name, - updated_at = CURRENT_TIMESTAMP` - ).run(sessionId, provider, customName); + custom_name = excluded.custom_name` + ).run(sessionId, provider, customName, nowIso, nowIso); }, /** diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts new file mode 100644 index 00000000..28287299 --- /dev/null +++ b/server/modules/providers/index.ts @@ -0,0 +1,4 @@ +export { sessionSynchronizerService } from './services/session-synchronizer.service.js'; + +export { initializeSessionsWatcher } from './services/sessions-watcher.service.js'; +export { closeSessionsWatcher } from './services/sessions-watcher.service.js'; \ No newline at end of file diff --git a/server/projects.js b/server/projects.js index 8b7b22e7..157e65e2 100755 --- a/server/projects.js +++ b/server/projects.js @@ -1,71 +1,39 @@ /** - * PROJECT DISCOVERY AND MANAGEMENT SYSTEM - * ======================================== - * - * This module manages project discovery for both Claude CLI and Cursor CLI sessions. - * - * ## Architecture Overview - * - * 1. **Claude Projects** (stored in ~/.claude/projects/) - * - Each project is a directory named with the project path encoded (/ replaced with -) - * - Contains .jsonl files with conversation history including 'cwd' field - * - Project metadata stored in ~/.claude/project-config.json - * - * 2. **Cursor Projects** (stored in ~/.cursor/chats/) - * - Each project directory is named with MD5 hash of the absolute project path - * - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6... - * - Contains session directories with SQLite databases (store.db) - * - Project path is NOT stored in the database - only in the MD5 hash - * - * ## Project Discovery Strategy - * - * 1. **Claude Projects Discovery**: - * - Scan ~/.claude/projects/ directory for Claude project folders - * - Extract actual project path from .jsonl files (cwd field) - * - Fall back to decoded directory name if no sessions exist - * - * 2. **Cursor Sessions Discovery**: - * - For each KNOWN project (from Claude or manually added) - * - Compute MD5 hash of the project's absolute path - * - Check if ~/.cursor/chats/{md5_hash}/ directory exists - * - Read session metadata from SQLite store.db files - * - * 3. **Manual Project Addition**: - * - Users can manually add project paths via UI - * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag - * - Allows discovering Cursor sessions for projects without Claude sessions - * - * ## Critical Limitations - * - * - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of - * the cwd of each project. if someone has the time, you can try to reverse engineer it. - * - * - **Project relocation breaks history**: If a project directory is moved or renamed, - * the MD5 hash changes, making old Cursor sessions inaccessible unless the old - * path is known and manually added. - * - * ## Error Handling - * - * - Missing ~/.claude directory is handled gracefully with automatic creation - * - ENOENT errors are caught and handled without crashing - * - Empty arrays returned when no projects/sessions exist - * - * ## Caching Strategy - * - * - Project directory extraction is cached to minimize file I/O - * - Cache is cleared when project configuration changes - * - Session data is fetched on-demand, not cached + * PROJECT DISCOVERY AND MANAGEMENT + * ================================ + * + * After the projectName → projectId migration, project and session listings + * for `GET /api/projects` are sourced entirely from the database: + * + * - `projects` table (via `projectsDb`) — the canonical list of projects and + * their absolute `project_path`. + * - `sessions` table (via `sessionsDb`) — every provider's sessions for a + * given project, keyed by `project_path`. + * + * Routes always address a project by its DB `projectId` and resolve the real + * directory through `getProjectPathById` before touching disk. + * + * The filesystem-aware helpers kept in this module serve the remaining + * features that still need on-disk data: + * - Session message reads for each provider (Claude/Codex/Gemini) for + * `GET /api/sessions/:sessionId/messages`. + * - Conversation search (`searchConversations`) which scans JSONL history. + * - Destructive project cleanup (`deleteProjectById` -> `deleteProject`) + * which removes Claude/Cursor/Codex artifacts on disk. + * - Manual project registration (`addProjectManually`) which syncs to + * ~/.claude/project-config.json for backwards compatibility. */ -import { promises as fs } from 'fs'; -import fsSync from 'fs'; +import fsSync, { promises as fs } from 'fs'; import path from 'path'; import readline from 'readline'; import crypto from 'crypto'; -import Database from 'better-sqlite3'; import os from 'os'; + +import { sessionSynchronizerService } from '@/modules/providers'; + import sessionManager from './sessionManager.js'; -import { applyCustomSessionNames } from './modules/database/index.js'; +import { projectsDb, sessionsDb } from './modules/database/index.js'; import { getModuleDir, findAppRoot } from './utils/runtime-paths.js'; // Snapshot files are kept as incrementing artifacts under .tmp/project-dumps for later review. @@ -265,12 +233,56 @@ function normalizeTaskMasterInfo(taskMasterResult = null) { }; } -async function getProjectTaskMaster(projectName) { - const projectPath = await extractProjectDirectory(projectName); +/** + * Resolve the absolute project path for a database `projectId`. + * + * After the projectName → projectId migration, every API route receives a + * `projectId` (the primary key from the `projects` table) and must translate + * it into the real directory on disk through this helper. Returns `null` when + * the id doesn't match any row so callers can respond with a 404. + */ +async function getProjectPathById(projectId) { + if (!projectId) { + return null; + } + + return projectsDb.getProjectPathById(projectId); +} + +/** + * Compute the Claude CLI project folder name for an absolute path. + * + * Claude stores its JSONL history per project under + * `~/.claude/projects//`. The folder name is derived from the + * absolute path by replacing every non-alphanumeric character (except `-`) with + * `-`. Filesystem helpers like `getSessions`/`deleteSession` still work on that + * folder name, so routes that receive a `projectId` compute it from the path + * resolved through the DB instead of keeping the encoded name as an identifier. + */ +function claudeFolderNameFromPath(projectPath) { + if (!projectPath) { + return ''; + } + + return projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); +} + +/** + * TaskMaster details for a project, addressed by DB `projectId`. + * + * Resolves the project path through the DB and inspects the `.taskmaster` + * folder on disk for metadata the TaskMaster panel displays. + */ +async function getProjectTaskMasterById(projectId) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + return null; + } + const taskMasterResult = await detectTaskMasterFolder(projectPath); return { - projectName, + projectId, projectPath, taskmaster: normalizeTaskMasterInfo(taskMasterResult) }; @@ -342,8 +354,10 @@ async function generateDisplayName(projectName, actualProjectDir = null) { return projectPath; } -// Extract the actual project directory from JSONL sessions (with caching) -// TODO: Get the project id as parameter and return the actual project directory from the database +// Resolve a Claude-encoded folder name back to an absolute project directory +// by inspecting cached metadata and JSONL `cwd` fields. Used only by the +// legacy name-based helpers below (`getSessions`, `deleteProject`, etc.) and +// by the conversation search; id-based routes use `getProjectPathById`. async function extractProjectDirectory(projectName) { // Check cache first if (projectDirectoryCache.has(projectName)) { @@ -463,209 +477,115 @@ async function extractProjectDirectory(projectName) { } } +/** + * Group the `sessions` table rows for a project by provider. + * + * After the projectId migration, GET /api/projects no longer scans JSONL files + * or any other session directory — every provider's session list comes from + * the database. One `SELECT ... WHERE project_path = ?` gets us every row we + * need, and we then bucket them by `provider` so each list (`sessions`, + * `cursorSessions`, `codexSessions`, `geminiSessions`) can be built without + * touching disk. Per the migration spec, each emitted session carries + * `summary = custom_name`, `messageCount = 0` and `lastActivity` taken from + * `updated_at` so the sidebar still sorts by recency. + */ +function buildSessionsByProviderFromDb(projectPath) { + const rows = sessionsDb.getSessionsByProjectPath(projectPath); + const byProvider = { + claude: [], + cursor: [], + codex: [], + gemini: [], + }; + + for (const row of rows) { + const bucket = byProvider[row.provider]; + if (!bucket) { + continue; + } + + bucket.push({ + id: row.session_id, + // The session summary intentionally mirrors the custom_name column only; + // the historical JSONL-derived summary is no longer computed on this path. + summary: row.custom_name || '', + // messageCount is always 0 for now — counting is not implemented yet. + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }); + } + + // Sort each bucket by recency so the sidebar's default ordering is preserved. + for (const provider of Object.keys(byProvider)) { + byProvider[provider].sort( + (a, b) => new Date(b.lastActivity) - new Date(a.lastActivity), + ); + } + + return byProvider; +} + async function getProjects(progressCallback = null) { - const claudeDir = path.join(os.homedir(), '.claude', 'projects'); - const config = await loadProjectConfig(); + await sessionSynchronizerService.synchronizeSessions(); + // Source of truth for project listing is now the `projects` and `sessions` + // tables — no directory scanning happens here. This keeps the API fast and + // lets the frontend identify projects by their stable DB `projectId`. + const projectRows = projectsDb.getProjectPaths(); + const totalProjects = projectRows.length; const projects = []; - const existingProjects = new Set(); - const codexSessionsIndexRef = { sessionsByProject: null }; - let totalProjects = 0; let processedProjects = 0; - let directories = []; - try { - // Check if the .claude/projects directory exists - await fs.access(claudeDir); + for (const row of projectRows) { + processedProjects++; - // First, get existing Claude projects from the file system - const entries = await fs.readdir(claudeDir, { withFileTypes: true }); - directories = entries.filter(e => e.isDirectory()); + const projectId = row.project_id; + const projectPath = row.project_path; - // Build set of existing project names for later - directories.forEach(e => existingProjects.add(e.name)); - - // Count manual projects not already in directories - const manualProjectsCount = Object.entries(config) - .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) - .length; - - totalProjects = directories.length + manualProjectsCount; - - for (const entry of directories) { - processedProjects++; - - // Emit progress - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: entry.name - }); - } - - // Extract actual project directory from JSONL sessions - const actualProjectDir = await extractProjectDirectory(entry.name); - - // Get display name from config or generate one - const customName = config[entry.name]?.displayName; - const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); - const fullPath = actualProjectDir; - - const project = { - name: entry.name, - path: actualProjectDir, - displayName: customName || autoDisplayName, - fullPath: fullPath, - sessions: [], - geminiSessions: [], - sessionMeta: { - hasMore: false, - total: 0 - } - }; - - // Try to get sessions for this project (just first 5 for performance) - try { - const sessionResult = await getSessions(entry.name, 5, 0); - project.sessions = sessionResult.sessions || []; - project.sessionMeta = { - hasMore: sessionResult.hasMore, - total: sessionResult.total - }; - } catch (e) { - console.warn(`Could not load sessions for project ${entry.name}:`, e.message); - project.sessionMeta = { - hasMore: false, - total: 0 - }; - } - applyCustomSessionNames(project.sessions, 'claude'); - - // Also fetch Cursor sessions for this project - try { - project.cursorSessions = await getCursorSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); - project.cursorSessions = []; - } - applyCustomSessionNames(project.cursorSessions, 'cursor'); - - // Also fetch Codex sessions for this project - try { - project.codexSessions = await getCodexSessions(actualProjectDir, { - indexRef: codexSessionsIndexRef, - }); - } catch (e) { - console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); - project.codexSessions = []; - } - applyCustomSessionNames(project.codexSessions, 'codex'); - - // Also fetch Gemini sessions for this project (UI + CLI) - try { - const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; - const cliSessions = await getGeminiCliSessions(actualProjectDir); - const uiIds = new Set(uiSessions.map(s => s.id)); - const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; - project.geminiSessions = mergedGemini; - } catch (e) { - console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message); - project.geminiSessions = []; - } - applyCustomSessionNames(project.geminiSessions, 'gemini'); - - projects.push(project); - // console.log(`Loaded project: ${project.displayName} (${project.name}) with ${project.sessions.length} sessions, ${project.cursorSessions.length} Cursor sessions, ${project.codexSessions.length} Codex sessions, and ${project.geminiSessions.length} Gemini sessions.`); - // console.log("Full project data:", project); + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectPath + }); } - } catch (error) { - // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects - if (error.code !== 'ENOENT') { - console.error('Error reading projects directory:', error); - } - // Calculate total for manual projects only (no directories exist) - totalProjects = Object.entries(config) - .filter(([name, cfg]) => cfg.manuallyAdded) - .length; + + // Use the stored custom name when present, otherwise fall back to a + // generated display name derived from the project path. + const displayName = row.custom_project_name && row.custom_project_name.trim().length > 0 + ? row.custom_project_name + : await generateDisplayName(path.basename(projectPath) || projectPath, projectPath); + + // All provider session lists are built from a single DB query — no JSONL + // parsing, no filesystem walks, no in-memory session manager lookups. + const sessionsByProvider = buildSessionsByProviderFromDb(projectPath); + const claudeSessionsAll = sessionsByProvider.claude; + const claudeSessions = claudeSessionsAll.slice(0, 5); + + const project = { + // Primary identifier used across the UI and API routes post-migration. + projectId, + path: projectPath, + displayName, + fullPath: projectPath, + sessions: claudeSessions, + cursorSessions: sessionsByProvider.cursor, + codexSessions: sessionsByProvider.codex, + geminiSessions: sessionsByProvider.gemini, + // hasMore is pinned to false per the migration spec — pagination on the + // project list is not driven by this endpoint anymore. + sessionMeta: { + hasMore: false, + total: claudeSessionsAll.length + } + }; + + // Custom-name overrides are already baked into each row's `summary` field + // by buildSessionsByProviderFromDb, so we don't need to re-apply them. + + projects.push(project); } - // Add manually configured projects that don't exist as folders yet - for (const [projectName, projectConfig] of Object.entries(config)) { - if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { - processedProjects++; - - // Emit progress for manual projects - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: projectName - }); - } - - // Use the original path if available, otherwise extract from potential sessions - let actualProjectDir = projectConfig.originalPath; - - if (!actualProjectDir) { - try { - actualProjectDir = await extractProjectDirectory(projectName); - } catch (error) { - // Fall back to decoded project name - actualProjectDir = projectName.replace(/-/g, '/'); - } - } - - const project = { - name: projectName, - path: actualProjectDir, - displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), - fullPath: actualProjectDir, - sessions: [], - geminiSessions: [], - sessionMeta: { - hasMore: false, - total: 0 - }, - cursorSessions: [], - codexSessions: [] - }; - - // Try to fetch Cursor sessions for manual projects too - try { - project.cursorSessions = await getCursorSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); - } - applyCustomSessionNames(project.cursorSessions, 'cursor'); - - // Try to fetch Codex sessions for manual projects too - try { - project.codexSessions = await getCodexSessions(actualProjectDir, { - indexRef: codexSessionsIndexRef, - }); - } catch (e) { - console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); - } - applyCustomSessionNames(project.codexSessions, 'codex'); - - // Try to fetch Gemini sessions for manual projects too (UI + CLI) - try { - const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; - const cliSessions = await getGeminiCliSessions(actualProjectDir); - const uiIds = new Set(uiSessions.map(s => s.id)); - project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; - } catch (e) { - console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message); - } - applyCustomSessionNames(project.geminiSessions, 'gemini'); - - projects.push(project); - } - } - - // Emit completion after all projects (including manual) are processed if (progressCallback) { progressCallback({ phase: 'complete', @@ -1117,6 +1037,26 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = } } +/** + * ID-based wrapper around `getSessions`. + * + * Resolves a `projectId` to the underlying Claude JSONL folder name (via the + * DB-backed project path) and defers to the legacy filesystem reader. Keeps + * the previous pagination shape so the sidebar's "Load more sessions" UI keeps + * working after the migration. + */ +async function getSessionsById(projectId, limit = 5, offset = 0) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + return { sessions: [], hasMore: false, total: 0 }; + } + + // Claude stores history under ~/.claude/projects//; derive the + // folder name from the absolute path the DB gave us. + const claudeFolderName = claudeFolderNameFromPath(projectPath); + return getSessions(claudeFolderName, limit, offset); +} + // Rename a project's display name async function renameProject(projectName, newDisplayName) { const config = await loadProjectConfig(); @@ -1138,6 +1078,53 @@ async function renameProject(projectName, newDisplayName) { return true; } +/** + * ID-based wrapper around `renameProject`. + * + * Writes the new display name to the `projects.custom_project_name` column + * (the source of truth for the DB-driven getProjects() response) and also + * keeps the legacy project-config.json in sync for backwards compatibility + * with any code that still reads it. + */ +async function renameProjectById(projectId, newDisplayName) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + throw new Error(`Unknown projectId: ${projectId}`); + } + + const trimmed = typeof newDisplayName === 'string' ? newDisplayName.trim() : ''; + // Persist on the DB row so getProjects() immediately reflects the change. + projectsDb.updateCustomProjectNameById(projectId, trimmed.length > 0 ? trimmed : null); + + // Keep the legacy file-based project config in lockstep so historic readers + // that still consult project-config.json don't diverge. + const claudeFolderName = claudeFolderNameFromPath(projectPath); + try { + await renameProject(claudeFolderName, trimmed); + } catch (error) { + console.warn(`[projects] Legacy renameProject sync failed for ${projectId}:`, error.message); + } + + return true; +} + +/** + * ID-based wrapper around `deleteSession`. + * + * Resolves the real Claude history folder via the DB-backed path, then defers + * to the filesystem deletion routine. Callers should still clean up any DB + * bookkeeping (e.g. the sessions table) at the route layer. + */ +async function deleteSessionById(projectId, sessionId) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + throw new Error(`Unknown projectId: ${projectId}`); + } + + const claudeFolderName = claudeFolderNameFromPath(projectPath); + return deleteSession(claudeFolderName, sessionId); +} + // Delete a session from a project async function deleteSession(projectName, sessionId) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); @@ -1261,6 +1248,35 @@ async function deleteProject(projectName, force = false, deleteData = false) { } } +/** + * ID-based wrapper around `deleteProject`. + * + * Resolves the project path via the DB, defers destructive filesystem cleanup + * to `deleteProject`, then removes the row from the `projects` table so the + * DB-driven GET /api/projects response no longer lists it. + */ +async function deleteProjectById(projectId, force = false, deleteData = false) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + throw new Error(`Unknown projectId: ${projectId}`); + } + + const claudeFolderName = claudeFolderNameFromPath(projectPath); + try { + await deleteProject(claudeFolderName, force, deleteData); + } catch (error) { + // If the legacy Claude folder doesn't exist anymore we still want to drop + // the DB row; rethrow otherwise so callers can surface the failure. + if (error.code !== 'ENOENT') { + throw error; + } + } + + // Drop the DB row so the DB-driven GET /api/projects stops listing it. + projectsDb.deleteProjectById(projectId); + return true; +} + // Add a project manually to the config (without creating folders) async function addProjectManually(projectPath, displayName = null) { const absolutePath = path.resolve(projectPath); @@ -1309,110 +1325,6 @@ async function addProjectManually(projectPath, displayName = null) { }; } -// Fetch Cursor sessions for a given project path -async function getCursorSessions(projectPath) { - try { - // Calculate cwdID hash for the project path (Cursor uses MD5 hash) - const cwdId = crypto.createHash('md5').update(projectPath).digest('hex'); - const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId); - - // Check if the directory exists - try { - await fs.access(cursorChatsPath); - } catch (error) { - // No sessions for this project - return []; - } - - // List all session directories - const sessionDirs = await fs.readdir(cursorChatsPath); - const sessions = []; - - for (const sessionId of sessionDirs) { - const sessionPath = path.join(cursorChatsPath, sessionId); - const storeDbPath = path.join(sessionPath, 'store.db'); - - try { - // Check if store.db exists - await fs.access(storeDbPath); - - // Capture store.db mtime as a reliable fallback timestamp - let dbStatMtimeMs = null; - try { - const stat = await fs.stat(storeDbPath); - dbStatMtimeMs = stat.mtimeMs; - } catch (_) { } - - // Open SQLite database - const db = new Database(storeDbPath, { readonly: true, fileMustExist: true }); - - // Get metadata from meta table - const metaRows = db.prepare('SELECT key, value FROM meta').all(); - - // Parse metadata - let metadata = {}; - for (const row of metaRows) { - if (row.value) { - try { - // Try to decode as hex-encoded JSON - const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/); - if (hexMatch) { - const jsonStr = Buffer.from(row.value, 'hex').toString('utf8'); - metadata[row.key] = JSON.parse(jsonStr); - } else { - metadata[row.key] = row.value.toString(); - } - } catch (e) { - metadata[row.key] = row.value.toString(); - } - } - } - - // Get message count - const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get(); - - db.close(); - - // Extract session info - const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; - - // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime - let createdAt = null; - if (metadata.createdAt) { - createdAt = new Date(metadata.createdAt).toISOString(); - } else if (dbStatMtimeMs) { - createdAt = new Date(dbStatMtimeMs).toISOString(); - } else { - createdAt = new Date().toISOString(); - } - - sessions.push({ - id: sessionId, - name: sessionName, - createdAt: createdAt, - lastActivity: createdAt, // For compatibility with Claude sessions - messageCount: messageCountResult.count || 0, - projectPath: projectPath - }); - - } catch (error) { - console.warn(`Could not read Cursor session ${sessionId}:`, error.message); - } - } - - // Sort sessions by creation time (newest first) - sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - - // Return only the first 5 sessions for performance - return sessions.slice(0, 5); - - } catch (error) { - console.error('Error fetching Cursor sessions:', error); - return []; - } -} - - function normalizeComparablePath(inputPath) { if (!inputPath || typeof inputPath !== 'string') { return ''; @@ -2011,7 +1923,23 @@ async function searchConversations(query, limit = 50, onProjectResult = null, si file => file.endsWith('.jsonl') && !file.startsWith('agent-') ); + // Also include the DB `projectId` so the frontend (which now identifies + // projects by `projectId`) can match search results to the + // currently-loaded project list without a second round-trip. + let searchProjectId = null; + try { + const resolvedPath = await extractProjectDirectory(projectName); + const dbRow = projectsDb.getProjectPath(resolvedPath); + if (dbRow?.project_id) { + searchProjectId = dbRow.project_id; + } + } catch { + // Best-effort: if we cannot resolve the projectId, the result is still + // usable on the backend but the frontend will skip the auto-select. + } + const projectResult = { + projectId: searchProjectId, projectName, projectDisplayName: displayName, sessions: [] @@ -2438,82 +2366,6 @@ async function searchGeminiSessionsForProject( } } -async function getGeminiCliSessions(projectPath) { - const normalizedProjectPath = normalizeComparablePath(projectPath); - if (!normalizedProjectPath) return []; - - const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); - try { - await fs.access(geminiTmpDir); - } catch { - return []; - } - - const sessions = []; - let projectDirs; - try { - projectDirs = await fs.readdir(geminiTmpDir); - } catch { - return []; - } - - for (const projectDir of projectDirs) { - const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); - let projectRoot; - try { - projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); - } catch { - continue; - } - - if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue; - - const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); - let chatFiles; - try { - chatFiles = await fs.readdir(chatsDir); - } catch { - continue; - } - - for (const chatFile of chatFiles) { - if (!chatFile.endsWith('.json')) continue; - try { - const filePath = path.join(chatsDir, chatFile); - const data = await fs.readFile(filePath, 'utf8'); - const session = JSON.parse(data); - if (!session.messages || !Array.isArray(session.messages)) continue; - - const sessionId = session.sessionId || chatFile.replace('.json', ''); - const firstUserMsg = session.messages.find(m => m.type === 'user'); - let summary = 'Gemini CLI Session'; - if (firstUserMsg) { - const text = Array.isArray(firstUserMsg.content) - ? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ') - : (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : ''); - if (text) { - summary = text.length > 50 ? text.substring(0, 50) + '...' : text; - } - } - - sessions.push({ - id: sessionId, - summary, - messageCount: session.messages.length, - lastActivity: session.lastUpdated || session.startTime || null, - provider: 'gemini' - }); - } catch { - continue; - } - } - } - - return sessions.sort((a, b) => - new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0) - ); -} - async function getGeminiCliSessionMessages(sessionId) { const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); let projectDirs; @@ -2568,20 +2420,23 @@ async function getGeminiCliSessionMessages(sessionId) { return []; } +// Only functions with consumers outside this module are exported. Folder-name +// based helpers (`getSessions`, `renameProject`, `deleteSession`, etc.) are +// kept as internal implementation details of the id-based wrappers below. export { getProjects, - getSessions, + getSessionsById, getSessionMessages, - renameProject, - deleteSession, - deleteProject, + renameProjectById, + deleteSessionById, + deleteProjectById, addProjectManually, - getProjectTaskMaster, - extractProjectDirectory, + getProjectTaskMasterById, + getProjectPathById, + claudeFolderNameFromPath, clearProjectDirectoryCache, getCodexSessionMessages, deleteCodexSession, - getGeminiCliSessions, getGeminiCliSessionMessages, searchConversations }; diff --git a/server/routes/git.js b/server/routes/git.js index a4395638..dbae5101 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -2,7 +2,7 @@ import express from 'express'; import { spawn } from 'child_process'; import path from 'path'; import { promises as fs } from 'fs'; -import { extractProjectDirectory } from '../projects.js'; +import { getProjectPathById } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; @@ -101,14 +101,19 @@ function validateProjectPath(projectPath) { return resolved; } -// Helper function to get the actual project path from the encoded project name -async function getActualProjectPath(projectName) { - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { - console.error(`Error extracting project directory for ${projectName}:`, error); - throw new Error(`Unable to resolve project path for "${projectName}"`); +/** + * Resolve the absolute project directory for a given DB `projectId`. + * + * After the projectName → projectId migration, every git endpoint receives + * the DB primary key (`project` query/body param). The legacy filesystem + * resolver that walked Claude's JSONL history is no longer used here; the + * path comes straight from the `projects` table and is then sanity-checked + * by `validateProjectPath` before any `git` command runs against it. + */ +async function getActualProjectPath(projectId) { + const projectPath = await getProjectPathById(projectId); + if (!projectPath) { + throw new Error(`Unable to resolve project path for "${projectId}"`); } return validateProjectPath(projectPath); } @@ -292,7 +297,7 @@ router.get('/status', async (req, res) => { const { project } = req.query; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -355,7 +360,7 @@ router.get('/diff', async (req, res) => { const { project, file } = req.query; if (!project || !file) { - return res.status(400).json({ error: 'Project name and file path are required' }); + return res.status(400).json({ error: 'Project id and file path are required' }); } try { @@ -438,7 +443,7 @@ router.get('/file-with-diff', async (req, res) => { const { project, file } = req.query; if (!project || !file) { - return res.status(400).json({ error: 'Project name and file path are required' }); + return res.status(400).json({ error: 'Project id and file path are required' }); } try { @@ -518,7 +523,7 @@ router.post('/initial-commit', async (req, res) => { const { project } = req.body; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -593,7 +598,7 @@ router.post('/revert-local-commit', async (req, res) => { const { project } = req.body; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -640,7 +645,7 @@ router.get('/branches', async (req, res) => { const { project } = req.query; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -684,7 +689,7 @@ router.post('/checkout', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { - return res.status(400).json({ error: 'Project name and branch are required' }); + return res.status(400).json({ error: 'Project id and branch are required' }); } try { @@ -706,7 +711,7 @@ router.post('/create-branch', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { - return res.status(400).json({ error: 'Project name and branch name are required' }); + return res.status(400).json({ error: 'Project id and branch name are required' }); } try { @@ -728,7 +733,7 @@ router.post('/delete-branch', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { - return res.status(400).json({ error: 'Project name and branch name are required' }); + return res.status(400).json({ error: 'Project id and branch name are required' }); } try { @@ -754,7 +759,7 @@ router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -811,7 +816,7 @@ router.get('/commit-diff', async (req, res) => { const { project, commit } = req.query; if (!project || !commit) { - return res.status(400).json({ error: 'Project name and commit hash are required' }); + return res.status(400).json({ error: 'Project id and commit hash are required' }); } try { @@ -843,7 +848,7 @@ router.post('/generate-commit-message', async (req, res) => { const { project, files, provider = 'claude' } = req.body; if (!project || !files || files.length === 0) { - return res.status(400).json({ error: 'Project name and files are required' }); + return res.status(400).json({ error: 'Project id and files are required' }); } // Validate provider @@ -1048,7 +1053,7 @@ router.get('/remote-status', async (req, res) => { const { project } = req.query; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -1126,7 +1131,7 @@ router.post('/fetch', async (req, res) => { const { project } = req.body; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -1167,7 +1172,7 @@ router.post('/pull', async (req, res) => { const { project } = req.body; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -1235,7 +1240,7 @@ router.post('/push', async (req, res) => { const { project } = req.body; if (!project) { - return res.status(400).json({ error: 'Project name is required' }); + return res.status(400).json({ error: 'Project id is required' }); } try { @@ -1306,7 +1311,7 @@ router.post('/publish', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { - return res.status(400).json({ error: 'Project name and branch are required' }); + return res.status(400).json({ error: 'Project id and branch are required' }); } try { @@ -1385,7 +1390,7 @@ router.post('/discard', async (req, res) => { const { project, file } = req.body; if (!project || !file) { - return res.status(400).json({ error: 'Project name and file path are required' }); + return res.status(400).json({ error: 'Project id and file path are required' }); } try { @@ -1439,7 +1444,7 @@ router.post('/delete-untracked', async (req, res) => { const { project, file } = req.body; if (!project || !file) { - return res.status(400).json({ error: 'Project name and file path are required' }); + return res.status(400).json({ error: 'Project id and file path are required' }); } try { diff --git a/server/routes/messages.js b/server/routes/messages.js index 81444d56..8aec2dd2 100644 --- a/server/routes/messages.js +++ b/server/routes/messages.js @@ -1,16 +1,21 @@ /** * Unified messages endpoint. * - * GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0 + * GET /api/sessions/:sessionId/messages?provider=claude&projectId=&limit=50&offset=0 * * Replaces the four provider-specific session message endpoints with a single route * that delegates to the appropriate adapter via the provider registry. * + * After the projectName → projectId migration, Claude history is located via the + * DB-backed project path lookup; the route accepts `projectId` (preferred) and + * resolves it to the underlying Claude folder name for the downstream adapter. + * * @module routes/messages */ import express from 'express'; import { sessionsService } from '../modules/providers/services/sessions.service.js'; +import { getProjectPathById, claudeFolderNameFromPath } from '../projects.js'; const router = express.Router(); @@ -21,7 +26,7 @@ const router = express.Router(); * * Query params: * provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude') - * projectName - required for claude provider + * projectId - DB primary key of the project (required for claude provider) * projectPath - required for cursor provider (absolute path used for cwdId hash) * limit - page size (omit or null for all) * offset - pagination offset (default: 0) @@ -30,7 +35,7 @@ router.get('/:sessionId/messages', async (req, res) => { try { const { sessionId } = req.params; const provider = String(req.query.provider || 'claude').trim().toLowerCase(); - const projectName = req.query.projectName || ''; + const projectId = req.query.projectId || ''; const projectPath = req.query.projectPath || ''; const limitParam = req.query.limit; const limit = limitParam !== undefined && limitParam !== null && limitParam !== '' @@ -44,8 +49,20 @@ router.get('/:sessionId/messages', async (req, res) => { return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` }); } + // The Claude adapter still reads sessions from ~/.claude/projects//, + // so we translate the caller's projectId into the encoded folder name via + // the DB-stored project path before delegating to the adapter. + let claudeProjectName = ''; + if (provider === 'claude' && projectId) { + const resolvedPath = await getProjectPathById(projectId); + if (!resolvedPath) { + return res.status(404).json({ error: 'Project not found' }); + } + claudeProjectName = claudeFolderNameFromPath(resolvedPath); + } + const result = await sessionsService.fetchHistory(provider, sessionId, { - projectName, + projectName: claudeProjectName, projectPath, limit, offset, diff --git a/server/routes/taskmaster.js b/server/routes/taskmaster.js index 54f7153a..9f054de2 100644 --- a/server/routes/taskmaster.js +++ b/server/routes/taskmaster.js @@ -13,10 +13,25 @@ import fs from 'fs'; import path from 'path'; import { promises as fsPromises } from 'fs'; import { spawn } from 'child_process'; -import { extractProjectDirectory } from '../projects.js'; +import { getProjectPathById } from '../projects.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js'; +/** + * Resolve the absolute project directory from a DB-assigned `projectId`. + * + * TaskMaster routes used to accept a Claude-encoded folder name (`projectName`) + * and derive the path from JSONL history. After the projectId migration the + * only identifier we accept is the primary key of the `projects` table, so + * every handler calls this helper and 404s when the id is unknown. + */ +async function resolveProjectPathFromId(projectId) { + if (!projectId) { + return null; + } + return getProjectPathById(projectId); +} + const router = express.Router(); /** @@ -132,21 +147,22 @@ router.get('/installation-status', async (req, res) => { }); /** - * GET /api/taskmaster/tasks/:projectName + * GET /api/taskmaster/tasks/:projectId * Load actual tasks from .taskmaster/tasks/tasks.json + * + * `projectId` is the DB primary key of the project; the folder is resolved via + * the projects table rather than extracted from Claude JSONL history. */ -router.get('/tasks/:projectName', async (req, res) => { +router.get('/tasks/:projectId', async (req, res) => { try { - const { projectName } = req.params; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + const { projectId } = req.params; + + // Get project path via the DB; the legacy JSONL-based resolver is gone. + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -158,7 +174,7 @@ router.get('/tasks/:projectName', async (req, res) => { await fsPromises.access(tasksFilePath); } catch (error) { return res.json({ - projectName, + projectId, tasks: [], message: 'No tasks.json file found' }); @@ -213,7 +229,7 @@ router.get('/tasks/:projectName', async (req, res) => { })); res.json({ - projectName, + projectId, projectPath, tasks: transformedTasks, currentTag, @@ -247,21 +263,19 @@ router.get('/tasks/:projectName', async (req, res) => { }); /** - * GET /api/taskmaster/prd/:projectName + * GET /api/taskmaster/prd/:projectId * List all PRD files in the project's .taskmaster/docs directory */ -router.get('/prd/:projectName', async (req, res) => { +router.get('/prd/:projectId', async (req, res) => { try { - const { projectName } = req.params; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + const { projectId } = req.params; + + // projectId → projectPath lookup through the DB (post-migration). + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -272,7 +286,7 @@ router.get('/prd/:projectName', async (req, res) => { await fsPromises.access(docsPath, fs.constants.R_OK); } catch (error) { return res.json({ - projectName, + projectId, prdFiles: [], message: 'No .taskmaster/docs directory found' }); @@ -299,7 +313,7 @@ router.get('/prd/:projectName', async (req, res) => { } res.json({ - projectName, + projectId, projectPath, prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)), timestamp: new Date().toISOString() @@ -323,12 +337,12 @@ router.get('/prd/:projectName', async (req, res) => { }); /** - * POST /api/taskmaster/prd/:projectName + * POST /api/taskmaster/prd/:projectId * Create or update a PRD file in the project's .taskmaster/docs directory */ -router.post('/prd/:projectName', async (req, res) => { +router.post('/prd/:projectId', async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { fileName, content } = req.body; if (!fileName || !content) { @@ -346,14 +360,12 @@ router.post('/prd/:projectName', async (req, res) => { }); } - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + // Resolve the project folder through the DB using the projectId param. + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -379,7 +391,7 @@ router.post('/prd/:projectName', async (req, res) => { const stats = await fsPromises.stat(filePath); res.json({ - projectName, + projectId, projectPath, fileName, filePath: path.relative(projectPath, filePath), @@ -408,21 +420,18 @@ router.post('/prd/:projectName', async (req, res) => { }); /** - * GET /api/taskmaster/prd/:projectName/:fileName + * GET /api/taskmaster/prd/:projectId/:fileName * Get content of a specific PRD file */ -router.get('/prd/:projectName/:fileName', async (req, res) => { +router.get('/prd/:projectId/:fileName', async (req, res) => { try { - const { projectName, fileName } = req.params; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + const { projectId, fileName } = req.params; + + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -444,7 +453,7 @@ router.get('/prd/:projectName/:fileName', async (req, res) => { const stats = await fsPromises.stat(filePath); res.json({ - projectName, + projectId, projectPath, fileName, filePath: path.relative(projectPath, filePath), @@ -473,21 +482,18 @@ router.get('/prd/:projectName/:fileName', async (req, res) => { }); /** - * POST /api/taskmaster/init/:projectName + * POST /api/taskmaster/init/:projectId * Initialize TaskMaster in a project */ -router.post('/init/:projectName', async (req, res) => { +router.post('/init/:projectId', async (req, res) => { try { - const { projectName } = req.params; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + const { projectId } = req.params; + + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -522,17 +528,19 @@ router.post('/init/:projectName', async (req, res) => { initProcess.on('close', (code) => { if (code === 0) { - // Broadcast TaskMaster project update via WebSocket + // Broadcast TaskMaster project update via WebSocket. The + // WebSocket payload keeps using `projectId` so the frontend + // can match notifications against the current selection. if (req.app.locals.wss) { broadcastTaskMasterProjectUpdate( - req.app.locals.wss, - projectName, + req.app.locals.wss, + projectId, { hasTaskmaster: true, status: 'initialized' } ); } res.json({ - projectName, + projectId, projectPath, message: 'TaskMaster initialized successfully', output: stdout, @@ -562,12 +570,12 @@ router.post('/init/:projectName', async (req, res) => { }); /** - * POST /api/taskmaster/add-task/:projectName + * POST /api/taskmaster/add-task/:projectId * Add a new task to the project */ -router.post('/add-task/:projectName', async (req, res) => { +router.post('/add-task/:projectId', async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { prompt, title, description, priority = 'medium', dependencies } = req.body; if (!prompt && (!title || !description)) { @@ -576,15 +584,12 @@ router.post('/add-task/:projectName', async (req, res) => { message: 'Either "prompt" or both "title" and "description" are required' }); } - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -629,16 +634,17 @@ router.post('/add-task/:projectName', async (req, res) => { console.log('Stderr:', stderr); if (code === 0) { - // Broadcast task update via WebSocket + // Broadcast task update via WebSocket using the projectId so + // clients subscribed to this project get notified immediately. if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate( - req.app.locals.wss, - projectName + req.app.locals.wss, + projectId ); } res.json({ - projectName, + projectId, projectPath, message: 'Task added successfully', output: stdout, @@ -666,22 +672,19 @@ router.post('/add-task/:projectName', async (req, res) => { }); /** - * PUT /api/taskmaster/update-task/:projectName/:taskId + * PUT /api/taskmaster/update-task/:projectId/:taskId * Update a specific task using TaskMaster CLI */ -router.put('/update-task/:projectName/:taskId', async (req, res) => { +router.put('/update-task/:projectId/:taskId', async (req, res) => { try { - const { projectName, taskId } = req.params; + const { projectId, taskId } = req.params; const { title, description, status, priority, details } = req.body; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -707,11 +710,11 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => { if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { - broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); + broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectId); } res.json({ - projectName, + projectId, projectPath, taskId, message: 'Task status updated successfully', @@ -759,11 +762,11 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => { if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { - broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); + broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectId); } res.json({ - projectName, + projectId, projectPath, taskId, message: 'Task updated successfully', @@ -793,22 +796,19 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => { }); /** - * POST /api/taskmaster/parse-prd/:projectName + * POST /api/taskmaster/parse-prd/:projectId * Parse a PRD file to generate tasks */ -router.post('/parse-prd/:projectName', async (req, res) => { +router.post('/parse-prd/:projectId', async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { fileName = 'prd.txt', numTasks, append = false } = req.body; - - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -859,13 +859,13 @@ router.post('/parse-prd/:projectName', async (req, res) => { // Broadcast task update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate( - req.app.locals.wss, - projectName + req.app.locals.wss, + projectId ); } res.json({ - projectName, + projectId, projectPath, prdFile: fileName, message: 'PRD parsed and tasks generated successfully', @@ -1340,12 +1340,12 @@ Description of the business problem, data sources, and expected insights. }); /** - * POST /api/taskmaster/apply-template/:projectName + * POST /api/taskmaster/apply-template/:projectId * Apply a PRD template to create a new PRD file */ -router.post('/apply-template/:projectName', async (req, res) => { +router.post('/apply-template/:projectId', async (req, res) => { try { - const { projectName } = req.params; + const { projectId } = req.params; const { templateId, fileName = 'prd.txt', customizations = {} } = req.body; if (!templateId) { @@ -1355,14 +1355,11 @@ router.post('/apply-template/:projectName', async (req, res) => { }); } - // Get project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { + const projectPath = await resolveProjectPathFromId(projectId); + if (!projectPath) { return res.status(404).json({ error: 'Project not found', - message: `Project "${projectName}" does not exist` + message: `Project "${projectId}" does not exist` }); } @@ -1401,7 +1398,7 @@ router.post('/apply-template/:projectName', async (req, res) => { await fsPromises.writeFile(filePath, content, 'utf8'); res.json({ - projectName, + projectId, projectPath, templateId, templateName: template.name, diff --git a/server/utils/taskmaster-websocket.js b/server/utils/taskmaster-websocket.js index 87c05498..001b3ecc 100644 --- a/server/utils/taskmaster-websocket.js +++ b/server/utils/taskmaster-websocket.js @@ -7,20 +7,25 @@ */ /** - * Broadcast TaskMaster project update to all connected clients + * Broadcast TaskMaster project update to all connected clients. + * + * The payload key is `projectId` post-migration so frontend listeners can + * match notifications against the DB-assigned project identifier they + * already use everywhere else. + * * @param {WebSocket.Server} wss - WebSocket server instance - * @param {string} projectName - Name of the updated project + * @param {string} projectId - DB id of the updated project * @param {Object} taskMasterData - Updated TaskMaster data */ -export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) { - if (!wss || !projectName) { - console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); +export function broadcastTaskMasterProjectUpdate(wss, projectId, taskMasterData) { + if (!wss || !projectId) { + console.warn('TaskMaster WebSocket broadcast: Missing wss or projectId'); return; } const message = { type: 'taskmaster-project-updated', - projectName, + projectId, taskMasterData, timestamp: new Date().toISOString() }; @@ -38,20 +43,21 @@ export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterDat } /** - * Broadcast TaskMaster tasks update for a specific project - * @param {WebSocket.Server} wss - WebSocket server instance - * @param {string} projectName - Name of the project with updated tasks + * Broadcast TaskMaster tasks update for a specific project. + * + * @param {WebSocket.Server} wss - WebSocket server instance + * @param {string} projectId - DB id of the project with updated tasks * @param {Object} tasksData - Updated tasks data */ -export function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) { - if (!wss || !projectName) { - console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); +export function broadcastTaskMasterTasksUpdate(wss, projectId, tasksData) { + if (!wss || !projectId) { + console.warn('TaskMaster WebSocket broadcast: Missing wss or projectId'); return; } const message = { type: 'taskmaster-tasks-updated', - projectName, + projectId, tasksData, timestamp: new Date().toISOString() }; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 3b167215..c53cd01d 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -135,7 +135,9 @@ export function useChatComposerState({ }: UseChatComposerStateArgs) { const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { - return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; + // Draft inputs are keyed by the DB projectId so per-project drafts + // survive display-name changes. + return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || ''; } return ''; }); @@ -276,9 +278,11 @@ export function useChatComposerState({ const args = commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; + // The `/api/commands/execute` context sends `projectId` now instead of + // a folder-derived project name; the path is still included verbatim. const context = { projectPath: selectedProject.fullPath || selectedProject.path, - projectName: selectedProject.name, + projectId: selectedProject.projectId, sessionId: currentSessionId, provider, model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, @@ -503,7 +507,7 @@ export function useChatComposerState({ }); try { - const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, { + const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, { method: 'POST', headers: {}, body: formData, @@ -669,7 +673,7 @@ export function useChatComposerState({ textareaRef.current.style.height = 'auto'; } - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); }, [ selectedSession, @@ -712,22 +716,22 @@ export function useChatComposerState({ if (!selectedProject) { return; } - const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; + const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || ''; setInput((previous) => { const next = previous === savedInput ? previous : savedInput; inputValueRef.current = next; return next; }); - }, [selectedProject?.name]); + }, [selectedProject?.projectId]); useEffect(() => { if (!selectedProject) { return; } if (input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); + safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input); } else { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`); } }, [input, selectedProject]); diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index b551060a..3ad66f82 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -241,7 +241,8 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchMore(selectedSession.id, { provider: sessionProvider as LLMProvider, - projectName: selectedProject.name, + // DB-assigned projectId replaces the legacy folder-derived name. + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, }); @@ -296,7 +297,7 @@ export function useChatSessionState({ topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; setIsUserScrolledUp(false); - }, [selectedProject?.name, selectedSession?.id]); + }, [selectedProject?.projectId, selectedSession?.id]); // Initial scroll to bottom useEffect(() => { @@ -325,7 +326,7 @@ export function useChatSessionState({ } const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; - const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`; + const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`; // Skip if already loaded and fresh if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { @@ -375,7 +376,7 @@ export function useChatSessionState({ setIsLoadingSessionMessages(true); sessionStore.fetchFromServer(selectedSession.id, { provider: (selectedSession.__provider || provider) as LLMProvider, - projectName: selectedProject.name, + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, offset: 0, @@ -411,7 +412,7 @@ export function useChatSessionState({ if (!isLoading) { await sessionStore.refreshFromServer(selectedSession.id, { provider: (selectedSession.__provider || provider) as LLMProvider, - projectName: selectedProject.name, + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', }); @@ -469,7 +470,7 @@ export function useChatSessionState({ // Load all messages into the store for search navigation const slot = await sessionStore.fetchFromServer(selectedSession.id, { provider: sessionProvider as LLMProvider, - projectName: selectedProject.name, + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, @@ -550,7 +551,8 @@ export function useChatSessionState({ const fetchInitialTokenUsage = async () => { try { - const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; + // Token usage endpoint is now keyed by the DB projectId. + const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`; const response = await authenticatedFetch(url); if (response.ok) { setTokenBudget(await response.json()); @@ -656,7 +658,7 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchFromServer(requestSessionId, { provider: sessionProvider as LLMProvider, - projectName: selectedProject.name, + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, diff --git a/src/components/chat/hooks/useFileMentions.tsx b/src/components/chat/hooks/useFileMentions.tsx index c53f4c7b..4061605d 100644 --- a/src/components/chat/hooks/useFileMentions.tsx +++ b/src/components/chat/hooks/useFileMentions.tsx @@ -59,16 +59,18 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef const abortController = new AbortController(); const fetchProjectFiles = async () => { - const projectName = selectedProject?.name; + // File list is keyed by DB projectId now; the backend resolves it to + // the project's path before reading. + const projectId = selectedProject?.projectId; setFileList([]); setFilteredFiles([]); - if (!projectName) { + if (!projectId) { return; } try { - const response = await api.getFiles(projectName, { signal: abortController.signal }); + const response = await api.getFiles(projectId, { signal: abortController.signal }); if (!response.ok) { return; } @@ -88,7 +90,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef return () => { abortController.abort(); }; - }, [selectedProject?.name]); + }, [selectedProject?.projectId]); useEffect(() => { const textBeforeCursor = input.slice(0, cursorPosition); diff --git a/src/components/chat/hooks/useSlashCommands.ts b/src/components/chat/hooks/useSlashCommands.ts index 067cd24d..89408420 100644 --- a/src/components/chat/hooks/useSlashCommands.ts +++ b/src/components/chat/hooks/useSlashCommands.ts @@ -114,7 +114,7 @@ export function useSlashCommands({ })), ]; - const parsedHistory = readCommandHistory(selectedProject.name); + const parsedHistory = readCommandHistory(selectedProject.projectId); const sortedCommands = [...allCommands].sort((commandA, commandB) => { const commandAUsage = parsedHistory[commandA.name] || 0; const commandBUsage = parsedHistory[commandB.name] || 0; @@ -173,7 +173,7 @@ export function useSlashCommands({ return []; } - const parsedHistory = readCommandHistory(selectedProject.name); + const parsedHistory = readCommandHistory(selectedProject.projectId); return slashCommands .map((command) => ({ @@ -191,9 +191,9 @@ export function useSlashCommands({ return; } - const parsedHistory = readCommandHistory(selectedProject.name); + const parsedHistory = readCommandHistory(selectedProject.projectId); parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; - saveCommandHistory(selectedProject.name, parsedHistory); + saveCommandHistory(selectedProject.projectId, parsedHistory); }, [selectedProject], ); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 1b3ae95c..2e923d7a 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -212,7 +212,8 @@ function ChatInterface({ const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; await sessionStore.refreshFromServer(selectedSession.id, { provider: (selectedSession.__provider || providerVal) as LLMProvider, - projectName: selectedProject.name, + // Use DB projectId; legacy folder-derived projectName is no longer accepted here. + projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', }); setIsLoading(false); diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts index 5e3adc3e..b2b7acd2 100644 --- a/src/components/code-editor/hooks/useCodeEditorDocument.ts +++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts @@ -23,7 +23,10 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(null); const [isBinary, setIsBinary] = useState(false); - const fileProjectName = file.projectName ?? projectPath; + // `fileProjectId` is the DB primary key passed down from the editor sidebar; + // the fallback to `projectPath` preserves older callers that didn't yet + // propagate the identifier. + const fileProjectId = file.projectId ?? projectPath; const filePath = file.path; const fileName = file.name; const fileDiffNewString = file.diffInfo?.new_string; @@ -49,11 +52,11 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume return; } - if (!fileProjectName) { + if (!fileProjectId) { throw new Error('Missing project identifier'); } - const response = await api.readFile(fileProjectName, filePath); + const response = await api.readFile(fileProjectId, filePath); if (!response.ok) { throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); } @@ -70,18 +73,18 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume }; loadFileContent(); - }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]); + }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]); const handleSave = useCallback(async () => { setSaving(true); setSaveError(null); try { - if (!fileProjectName) { + if (!fileProjectId) { throw new Error('Missing project identifier'); } - const response = await api.saveFile(fileProjectName, filePath, content); + const response = await api.saveFile(fileProjectId, filePath, content); if (!response.ok) { const contentType = response.headers.get('content-type'); @@ -106,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume } finally { setSaving(false); } - }, [content, filePath, fileProjectName]); + }, [content, filePath, fileProjectId]); const handleDownload = useCallback(() => { const blob = new Blob([content], { type: 'text/plain' }); diff --git a/src/components/code-editor/hooks/useEditorSidebar.ts b/src/components/code-editor/hooks/useEditorSidebar.ts index d5a650b4..87e4303d 100644 --- a/src/components/code-editor/hooks/useEditorSidebar.ts +++ b/src/components/code-editor/hooks/useEditorSidebar.ts @@ -29,11 +29,13 @@ export const useEditorSidebar = ({ setEditingFile({ name: fileName, path: filePath, - projectName: selectedProject?.name, + // DB projectId is forwarded to the editor so it can read/save files + // via `/api/projects/:projectId/file` endpoints. + projectId: selectedProject?.projectId, diffInfo, }); }, - [selectedProject?.name], + [selectedProject?.projectId], ); const handleCloseEditor = useCallback(() => { diff --git a/src/components/code-editor/types/types.ts b/src/components/code-editor/types/types.ts index 8427a5e0..799868c5 100644 --- a/src/components/code-editor/types/types.ts +++ b/src/components/code-editor/types/types.ts @@ -7,7 +7,9 @@ export type CodeEditorDiffInfo = { export type CodeEditorFile = { name: string; path: string; - projectName?: string; + // DB projectId; used by the editor to build `/api/projects/:projectId/file` + // URLs for reading and saving content. + projectId?: string; diffInfo?: CodeEditorDiffInfo | null; [key: string]: unknown; }; diff --git a/src/components/file-tree/hooks/useFileTreeData.ts b/src/components/file-tree/hooks/useFileTreeData.ts index 2ac88162..0a7a9b86 100644 --- a/src/components/file-tree/hooks/useFileTreeData.ts +++ b/src/components/file-tree/hooks/useFileTreeData.ts @@ -20,9 +20,11 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat }, []); useEffect(() => { - const projectName = selectedProject?.name; + // File-tree requests use the DB projectId; the backend resolves it to the + // project's absolute path through the projects table. + const projectId = selectedProject?.projectId; - if (!projectName) { + if (!projectId) { setFiles([]); setLoading(false); return; @@ -42,7 +44,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat setLoading(true); } try { - const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal }); + const response = await api.getFiles(projectId, { signal: abortControllerRef.current!.signal }); if (!response.ok) { const errorText = await response.text(); @@ -79,7 +81,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat isActive = false; abortControllerRef.current?.abort(); }; - }, [selectedProject?.name, refreshKey]); + }, [selectedProject?.projectId, refreshKey]); return { files, diff --git a/src/components/file-tree/hooks/useFileTreeOperations.ts b/src/components/file-tree/hooks/useFileTreeOperations.ts index 398fcbe5..559654c6 100644 --- a/src/components/file-tree/hooks/useFileTreeOperations.ts +++ b/src/components/file-tree/hooks/useFileTreeOperations.ts @@ -126,7 +126,7 @@ export function useFileTreeOperations({ setOperationLoading(true); try { - const response = await api.renameFile(selectedProject.name, { + const response = await api.renameFile(selectedProject.projectId, { oldPath: renamingItem.path, newName: renameValue, }); @@ -161,7 +161,7 @@ export function useFileTreeOperations({ setOperationLoading(true); try { - const response = await api.deleteFile(selectedProject.name, { + const response = await api.deleteFile(selectedProject.projectId, { path: item.path, type: item.type, }); @@ -212,7 +212,7 @@ export function useFileTreeOperations({ setOperationLoading(true); try { - const response = await api.createFile(selectedProject.name, { + const response = await api.createFile(selectedProject.projectId, { path: newItemParent, type: newItemType, name: newItemName, @@ -287,7 +287,7 @@ export function useFileTreeOperations({ if (!selectedProject) return; // Use the binary streaming endpoint so downloads preserve raw bytes. - const response = await api.readFileBlob(selectedProject.name, item.path); + const response = await api.readFileBlob(selectedProject.projectId, item.path); if (!response.ok) { throw new Error('Failed to download file'); @@ -308,7 +308,7 @@ export function useFileTreeOperations({ const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name; if (node.type === 'file') { - const response = await api.readFileBlob(selectedProject.name, node.path); + const response = await api.readFileBlob(selectedProject.projectId, node.path); if (!response.ok) { throw new Error(`Failed to download "${node.name}" for ZIP export`); } diff --git a/src/components/file-tree/hooks/useFileTreeUpload.ts b/src/components/file-tree/hooks/useFileTreeUpload.ts index c512091a..6879e3ae 100644 --- a/src/components/file-tree/hooks/useFileTreeUpload.ts +++ b/src/components/file-tree/hooks/useFileTreeUpload.ts @@ -154,7 +154,8 @@ export const useFileTreeUpload = ({ formData.append('relativePaths', JSON.stringify(relativePaths)); const response = await api.post( - `/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`, + // File upload endpoint is keyed by DB projectId post-migration. + `/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`, formData ); diff --git a/src/components/file-tree/types/types.ts b/src/components/file-tree/types/types.ts index fb2ac842..1cdb8194 100644 --- a/src/components/file-tree/types/types.ts +++ b/src/components/file-tree/types/types.ts @@ -19,7 +19,8 @@ export interface FileTreeImageSelection { name: string; path: string; projectPath?: string; - projectName: string; + // DB projectId; used by ImageViewer to build the raw content URL. + projectId: string; } export interface FileIconData { diff --git a/src/components/file-tree/view/FileTree.tsx b/src/components/file-tree/view/FileTree.tsx index e847613c..b42e1014 100644 --- a/src/components/file-tree/view/FileTree.tsx +++ b/src/components/file-tree/view/FileTree.tsx @@ -101,7 +101,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) name: item.name, path: item.path, projectPath: selectedProject.path, - projectName: selectedProject.name, + // Image URL uses the DB projectId so ImageViewer can hit the + // /api/projects/:projectId/files/content endpoint directly. + projectId: selectedProject.projectId, }); return; } diff --git a/src/components/file-tree/view/ImageViewer.tsx b/src/components/file-tree/view/ImageViewer.tsx index 0d151090..771b1f01 100644 --- a/src/components/file-tree/view/ImageViewer.tsx +++ b/src/components/file-tree/view/ImageViewer.tsx @@ -10,7 +10,7 @@ type ImageViewerProps = { }; export default function ImageViewer({ file, onClose }: ImageViewerProps) { - const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`; + const imagePath = `/api/projects/${file.projectId}/files/content?path=${encodeURIComponent(file.path)}`; const [imageUrl, setImageUrl] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts index cb34cf13..4ef54002 100644 --- a/src/components/git-panel/hooks/useGitPanelController.ts +++ b/src/components/git-panel/hooks/useGitPanelController.ts @@ -64,10 +64,12 @@ export function useGitPanelController({ const [operationError, setOperationError] = useState(null); const clearOperationError = useCallback(() => setOperationError(null), []); - const selectedProjectNameRef = useRef(selectedProject?.name ?? null); + // Tracks the DB projectId so async requests can detect stale responses when + // the user switches projects mid-flight. + const selectedProjectIdRef = useRef(selectedProject?.projectId ?? null); useEffect(() => { - selectedProjectNameRef.current = selectedProject?.name ?? null; + selectedProjectIdRef.current = selectedProject?.projectId ?? null; }, [selectedProject]); const provider = useSelectedProvider(); @@ -78,18 +80,19 @@ export function useGitPanelController({ return; } - const projectName = selectedProject.name; + // Git endpoints receive the DB projectId via the `project` query param. + const projectId = selectedProject.projectId; try { const response = await fetchWithAuth( - `/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`, + `/api/git/diff?project=${encodeURIComponent(projectId)}&file=${encodeURIComponent(filePath)}`, { signal }, ); const data = await readJson(response, signal); if ( signal?.aborted || - selectedProjectNameRef.current !== projectName + selectedProjectIdRef.current !== projectId ) { return; } @@ -116,16 +119,17 @@ export function useGitPanelController({ return; } - const projectName = selectedProject.name; + // `project` query param carries the DB projectId everywhere now. + const projectId = selectedProject.projectId; setIsLoading(true); try { - const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal }); + const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectId)}`, { signal }); const data = await readJson(response, signal); if ( signal?.aborted || - selectedProjectNameRef.current !== projectName + selectedProjectIdRef.current !== projectId ) { return; } @@ -150,7 +154,7 @@ export function useGitPanelController({ } if ( - selectedProjectNameRef.current !== projectName + selectedProjectIdRef.current !== projectId ) { return; } @@ -169,7 +173,7 @@ export function useGitPanelController({ } try { - const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`); + const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.projectId)}`); const data = await readJson(response); if (!data.error && data.branches) { @@ -196,7 +200,7 @@ export function useGitPanelController({ } try { - const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`); + const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.projectId)}`); const data = await readJson(response); if (!data.error) { @@ -222,7 +226,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, branch: branchName, }), }); @@ -257,7 +261,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, branch: trimmedBranchName, }), }); @@ -290,7 +294,7 @@ export function useGitPanelController({ const response = await fetchWithAuth('/api/git/delete-branch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project: selectedProject.name, branch: branchName }), + body: JSON.stringify({ project: selectedProject.projectId, branch: branchName }), }); const data = await readJson(response); @@ -320,7 +324,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, }), }); @@ -351,7 +355,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, }), }); @@ -381,7 +385,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, }), }); @@ -411,7 +415,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, branch: currentBranch, }), }); @@ -442,7 +446,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, file: filePath, }), }); @@ -472,7 +476,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, file: filePath, }), }); @@ -498,7 +502,7 @@ export function useGitPanelController({ try { const response = await fetchWithAuth( - `/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`, + `/api/git/commits?project=${encodeURIComponent(selectedProject.projectId)}&limit=${RECENT_COMMITS_LIMIT}`, ); const data = await readJson(response); @@ -518,7 +522,7 @@ export function useGitPanelController({ try { const response = await fetchWithAuth( - `/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`, + `/api/git/commit-diff?project=${encodeURIComponent(selectedProject.projectId)}&commit=${commitHash}`, ); const data = await readJson(response); @@ -546,7 +550,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, files, provider, }), @@ -578,7 +582,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, message, files, }), @@ -612,7 +616,7 @@ export function useGitPanelController({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - project: selectedProject.name, + project: selectedProject.projectId, }), }); @@ -645,7 +649,7 @@ export function useGitPanelController({ try { const response = await fetchWithAuth( - `/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`, + `/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.projectId)}&file=${encodeURIComponent(filePath)}`, ); const data = await readJson(response); diff --git a/src/components/git-panel/hooks/useRevertLocalCommit.ts b/src/components/git-panel/hooks/useRevertLocalCommit.ts index 3c3ea918..86929528 100644 --- a/src/components/git-panel/hooks/useRevertLocalCommit.ts +++ b/src/components/git-panel/hooks/useRevertLocalCommit.ts @@ -3,7 +3,9 @@ import { authenticatedFetch } from '../../../utils/api'; import type { GitOperationResponse } from '../types/types'; type UseRevertLocalCommitOptions = { - projectName: string | null; + // DB primary key for the project; forwarded to the git API via the + // `project` body param. + projectId: string | null; onSuccess?: () => void; }; @@ -11,11 +13,11 @@ async function readJson(response: Response): Promise { return (await response.json()) as T; } -export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) { +export function useRevertLocalCommit({ projectId, onSuccess }: UseRevertLocalCommitOptions) { const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false); const revertLatestLocalCommit = useCallback(async () => { - if (!projectName) { + if (!projectId) { return; } @@ -24,7 +26,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC const response = await authenticatedFetch('/api/git/revert-local-commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project: projectName }), + body: JSON.stringify({ project: projectId }), }); const data = await readJson(response); @@ -39,7 +41,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC } finally { setIsRevertingLocalCommit(false); } - }, [onSuccess, projectName]); + }, [onSuccess, projectId]); return { isRevertingLocalCommit, diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index fc6438bd..de9891dd 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -58,7 +58,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }); const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({ - projectName: selectedProject?.name ?? null, + // `projectId` (DB primary key) is forwarded to the revert API which uses it + // as the `project` body param. + projectId: selectedProject?.projectId ?? null, onSuccess: refreshAll, }); diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 89197150..bf0b87fc 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -73,13 +73,15 @@ function MainContent({ }); useEffect(() => { - const selectedProjectName = selectedProject?.name; - const currentProjectName = currentProject?.name; + // Identify projects by DB `projectId`; the TaskMaster context uses the + // same identifier to key its internal maps. + const selectedProjectId = selectedProject?.projectId; + const currentProjectId = currentProject?.projectId; - if (selectedProject && selectedProjectName !== currentProjectName) { + if (selectedProject && selectedProjectId !== currentProjectId) { setCurrentProject?.(selectedProject); } - }, [selectedProject, currentProject?.name, setCurrentProject]); + }, [selectedProject, currentProject?.projectId, setCurrentProject]); useEffect(() => { if (!shouldShowTasksTab && activeTab === 'tasks') { diff --git a/src/components/mcp/hooks/useMcpServerForm.ts b/src/components/mcp/hooks/useMcpServerForm.ts index 52809cbe..f38ef83e 100644 --- a/src/components/mcp/hooks/useMcpServerForm.ts +++ b/src/components/mcp/hooks/useMcpServerForm.ts @@ -128,7 +128,8 @@ export function useMcpServerForm({ currentProjects .map((project) => ({ value: getProjectPath(project), - label: project.displayName || project.name, + // Fall back to projectId (DB primary key) when no display name is set. + label: project.displayName || project.projectId, })) .filter((project) => project.value) ), [currentProjects]); diff --git a/src/components/mcp/hooks/useMcpServers.ts b/src/components/mcp/hooks/useMcpServers.ts index 57ed81cc..e9cb2d3c 100644 --- a/src/components/mcp/hooks/useMcpServers.ts +++ b/src/components/mcp/hooks/useMcpServers.ts @@ -31,6 +31,8 @@ type GlobalMcpServerResponse = { results: GlobalMcpServerResult[]; }; +// Internal MCP-side shape; `name` is now filled from the DB projectId since +// the legacy Project.name field was removed during the projectId migration. type ProjectTarget = { name: string; displayName: string; @@ -111,6 +113,9 @@ const normalizeServer = ( bearerTokenEnvVar: server.bearerTokenEnvVar, envHttpHeaders: server.envHttpHeaders ?? {}, workspacePath: project?.path || server.workspacePath, + // Keep the `projectName` key in the MCP wire payload for backwards + // compatibility. ProjectTarget.name is populated from the DB `projectId` + // (see createProjectTargets) so this still carries the new identifier. projectName: project?.name || server.projectName, projectDisplayName: project?.displayName || server.projectDisplayName, }; @@ -126,8 +131,9 @@ const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => { seen.add(projectPath); acc.push({ - name: project.name, - displayName: project.displayName || project.name, + // Use projectId as the stable internal identifier. + name: project.projectId, + displayName: project.displayName || project.projectId, path: projectPath, }); return acc; diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index 810258e9..2e3b618d 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -7,8 +7,10 @@ export type McpImportMode = 'form' | 'json'; export type McpFormMode = 'provider' | 'global'; export type KeyValueMap = Record; +// Internal MCP shape; `projectId` replaces the legacy `name` field from the +// projectName → projectId migration. export type McpProject = { - name: string; + projectId: string; displayName?: string; fullPath?: string; path?: string; diff --git a/src/components/plugins/view/PluginTabContent.tsx b/src/components/plugins/view/PluginTabContent.tsx index f3340738..7e7f8db5 100644 --- a/src/components/plugins/view/PluginTabContent.tsx +++ b/src/components/plugins/view/PluginTabContent.tsx @@ -12,6 +12,9 @@ type PluginTabContentProps = { type PluginContext = { theme: 'dark' | 'light'; + // Plugin contract historically used `name` for the project identifier; we + // keep that key and populate it from the DB `projectId` so external plugins + // continue to receive a stable opaque id. project: { name: string; path: string } | null; session: { id: string; title: string } | null; }; @@ -25,7 +28,7 @@ function buildContext( theme: isDarkMode ? 'dark' : 'light', project: selectedProject ? { - name: selectedProject.name, + name: selectedProject.projectId, path: selectedProject.fullPath || selectedProject.path || '', } : null, diff --git a/src/components/prd-editor/PRDEditor.tsx b/src/components/prd-editor/PRDEditor.tsx index 3ad89d63..5cf23e77 100644 --- a/src/components/prd-editor/PRDEditor.tsx +++ b/src/components/prd-editor/PRDEditor.tsx @@ -39,14 +39,16 @@ export default function PRDEditor({ projectPath, }); + // PRD hooks are now addressed by DB `projectId`; the backend resolves the + // `.taskmaster/docs` folder from the `projects` table. const { existingPrds, refreshExistingPrds } = usePrdRegistry({ - projectName: project?.name, + projectId: project?.projectId, }); const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]); const { savePrd, saving, saveSuccess } = usePrdSave({ - projectName: project?.name, + projectId: project?.projectId, existingPrds, isExistingFile, onAfterSave: async () => { diff --git a/src/components/prd-editor/hooks/usePrdDocument.ts b/src/components/prd-editor/hooks/usePrdDocument.ts index 3728caf4..9d817d16 100644 --- a/src/components/prd-editor/hooks/usePrdDocument.ts +++ b/src/components/prd-editor/hooks/usePrdDocument.ts @@ -73,7 +73,7 @@ export function usePrdDocument({ return; } - if (!file?.projectName || !file?.path) { + if (!file?.projectId || !file?.path) { if (!isMounted) { return; } @@ -87,7 +87,8 @@ export function usePrdDocument({ try { setLoading(true); - const response = await api.readFile(file.projectName, file.path); + // readFile uses the DB projectId to resolve the project's path server-side. + const response = await api.readFile(file.projectId, file.path); if (!response.ok) { throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); } diff --git a/src/components/prd-editor/hooks/usePrdRegistry.ts b/src/components/prd-editor/hooks/usePrdRegistry.ts index f7a40856..f28b1665 100644 --- a/src/components/prd-editor/hooks/usePrdRegistry.ts +++ b/src/components/prd-editor/hooks/usePrdRegistry.ts @@ -3,7 +3,8 @@ import { api } from '../../../utils/api'; import type { ExistingPrdFile, PrdListResponse } from '../types'; type UsePrdRegistryArgs = { - projectName?: string; + // DB primary key of the project (post migration). + projectId?: string; }; type UsePrdRegistryResult = { @@ -15,17 +16,17 @@ function getPrdFiles(data: PrdListResponse): ExistingPrdFile[] { return data.prdFiles || data.prds || []; } -export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegistryResult { +export function usePrdRegistry({ projectId }: UsePrdRegistryArgs): UsePrdRegistryResult { const [existingPrds, setExistingPrds] = useState([]); const refreshExistingPrds = useCallback(async () => { - if (!projectName) { + if (!projectId) { setExistingPrds([]); return; } try { - const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`); + const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectId)}`); if (!response.ok) { setExistingPrds([]); return; @@ -37,7 +38,7 @@ export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegis console.error('Failed to fetch existing PRDs:', error); setExistingPrds([]); } - }, [projectName]); + }, [projectId]); useEffect(() => { void refreshExistingPrds(); diff --git a/src/components/prd-editor/hooks/usePrdSave.ts b/src/components/prd-editor/hooks/usePrdSave.ts index 1d802ad5..b216f6cb 100644 --- a/src/components/prd-editor/hooks/usePrdSave.ts +++ b/src/components/prd-editor/hooks/usePrdSave.ts @@ -4,7 +4,8 @@ import type { ExistingPrdFile, SavePrdInput, SavePrdResult } from '../types'; import { ensurePrdExtension } from '../utils/fileName'; type UsePrdSaveArgs = { - projectName?: string; + // DB primary key of the project (post migration). + projectId?: string; existingPrds: ExistingPrdFile[]; isExistingFile: boolean; onAfterSave?: () => Promise; @@ -17,7 +18,7 @@ type UsePrdSaveResult = { }; export function usePrdSave({ - projectName, + projectId, existingPrds, isExistingFile, onAfterSave, @@ -44,7 +45,7 @@ export function usePrdSave({ return { status: 'failed', message: 'Please provide a filename for the PRD.' }; } - if (!projectName) { + if (!projectId) { return { status: 'failed', message: 'No project selected. Please reopen the editor.' }; } @@ -59,7 +60,7 @@ export function usePrdSave({ setSaving(true); try { - const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectName)}`, { + const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectId)}`, { method: 'POST', body: JSON.stringify({ fileName: finalFileName, @@ -100,7 +101,7 @@ export function usePrdSave({ setSaving(false); } }, - [existingPrds, isExistingFile, onAfterSave, projectName], + [existingPrds, isExistingFile, onAfterSave, projectId], ); return { diff --git a/src/components/prd-editor/types.ts b/src/components/prd-editor/types.ts index c9ddfd85..8cac8c15 100644 --- a/src/components/prd-editor/types.ts +++ b/src/components/prd-editor/types.ts @@ -1,7 +1,8 @@ export type PrdFile = { name?: string; path?: string; - projectName?: string; + // DB projectId used to resolve the project path when fetching file content. + projectId?: string; content?: string; isExisting?: boolean; }; diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx index caeca7c1..6f9fee5a 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx @@ -1,4 +1,5 @@ import type { AgentCategoryContentSectionProps } from '../types'; +import type { McpProject } from '../../../../../mcp/types'; import { McpServers } from '../../../../../mcp'; import AccountContent from './content/AccountContent'; @@ -71,9 +72,16 @@ export default function AgentCategoryContentSection({ )} {selectedCategory === 'mcp' && ( + // SettingsProject.name is populated from the DB projectId by + // normalizeProjectForSettings, so we can map it straight through. ((project) => ({ + projectId: project.name, + displayName: project.displayName, + fullPath: project.fullPath, + path: project.path, + }))} /> )} diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index b21f13ad..35f608e9 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -42,6 +42,9 @@ type ConversationSession = { }; type ConversationProjectResult = { + // Emitted by server/projects.js#searchConversations so the sidebar can map a + // match back to the Project in its current state by projectId. + projectId: string | null; projectName: string; projectDisplayName: string; sessions: ConversationSession[]; @@ -69,7 +72,8 @@ type UseSidebarControllerArgs = { onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onSessionDelete?: (sessionId: string) => void; - onProjectDelete?: (projectName: string) => void; + // `projectId` is the DB-assigned identifier; callbacks use that post-migration. + onProjectDelete?: (projectId: string) => void; setCurrentProject: (project: Project) => void; setSidebarVisible: (visible: boolean) => void; sidebarVisible: boolean; @@ -135,13 +139,15 @@ export function useSidebarController({ }, [projects]); useEffect(() => { + // Expanded-project tracking is now keyed by the DB `projectId` so state + // survives display-name edits and other mutations. if (selectedProject) { setExpandedProjects((prev) => { - if (prev.has(selectedProject.name)) { + if (prev.has(selectedProject.projectId)) { return prev; } const next = new Set(prev); - next.add(selectedProject.name); + next.add(selectedProject.projectId); return next; }); } @@ -152,7 +158,7 @@ export function useSidebarController({ const loadedProjects = new Set(); projects.forEach((project) => { if (project.sessions && project.sessions.length >= 0) { - loadedProjects.add(project.name); + loadedProjects.add(project.projectId); } }); setInitialSessionsLoaded(loadedProjects); @@ -296,30 +302,34 @@ export function useSidebarController({ [], ); - const toggleProject = useCallback((projectName: string) => { + // All sidebar state keys (expanded, starred, loading, etc.) use the DB + // `projectId` as their identifier after the migration. + const toggleProject = useCallback((projectId: string) => { setExpandedProjects((prev) => { const next = new Set(); - if (!prev.has(projectName)) { - next.add(projectName); + if (!prev.has(projectId)) { + next.add(projectId); } return next; }); }, []); const handleSessionClick = useCallback( - (session: SessionWithProvider, projectName: string) => { - onSessionSelect({ ...session, __projectName: projectName }); + (session: SessionWithProvider, projectId: string) => { + // Tag the session with its owning projectId so downstream handlers + // can correlate it with the selectedProject in the app state. + onSessionSelect({ ...session, __projectId: projectId }); }, [onSessionSelect], ); - const toggleStarProject = useCallback((projectName: string) => { + const toggleStarProject = useCallback((projectId: string) => { setStarredProjects((prev) => { const next = new Set(prev); - if (next.has(projectName)) { - next.delete(projectName); + if (next.has(projectId)) { + next.delete(projectId); } else { - next.add(projectName); + next.add(projectId); } persistStarredProjects(next); @@ -328,7 +338,7 @@ export function useSidebarController({ }, []); const isProjectStarred = useCallback( - (projectName: string) => starredProjects.has(projectName), + (projectId: string) => starredProjects.has(projectId), [starredProjects], ); @@ -340,7 +350,8 @@ export function useSidebarController({ const projectsWithSessionMeta = useMemo( () => projects.map((project) => { - const hasMoreOverride = projectHasMoreOverrides[project.name]; + // The `hasMore` override map is keyed by projectId (see loadMoreSessions). + const hasMoreOverride = projectHasMoreOverrides[project.projectId]; if (hasMoreOverride === undefined) { return project; } @@ -364,7 +375,9 @@ export function useSidebarController({ ); const startEditing = useCallback((project: Project) => { - setEditingProject(project.name); + // `editingProject` is keyed by projectId so it stays stable across + // display-name mutations that happen while the input is open. + setEditingProject(project.projectId); setEditingName(project.displayName); }, []); @@ -374,9 +387,11 @@ export function useSidebarController({ }, []); const saveProjectName = useCallback( - async (projectName: string) => { + // `projectId` is the DB primary key; the rename API resolves the path + // through the `projects` table before writing the new display name. + async (projectId: string) => { try { - const response = await api.renameProject(projectName, editingName); + const response = await api.renameProject(projectId, editingName); if (response.ok) { if (window.refreshProjects) { await window.refreshProjects(); @@ -397,13 +412,15 @@ export function useSidebarController({ ); const showDeleteSessionConfirmation = useCallback( + // `projectId` (not the legacy folder-encoded name) is what the DELETE + // /api/projects/:projectId/sessions/:sessionId endpoint expects. ( - projectName: string, + projectId: string, sessionId: string, sessionTitle: string, provider: SessionDeleteConfirmation['provider'] = 'claude', ) => { - setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider }); + setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider }); }, [], ); @@ -413,7 +430,7 @@ export function useSidebarController({ return; } - const { projectName, sessionId, provider } = sessionDeleteConfirmation; + const { projectId, sessionId, provider } = sessionDeleteConfirmation; setSessionDeleteConfirmation(null); try { @@ -423,7 +440,8 @@ export function useSidebarController({ } else if (provider === 'gemini') { response = await api.deleteGeminiSession(sessionId); } else { - response = await api.deleteSession(projectName, sessionId); + // Claude sessions are owned by the DB project row; pass projectId. + response = await api.deleteSession(projectId, sessionId); } if (response.ok) { @@ -461,13 +479,15 @@ export function useSidebarController({ const isEmpty = sessionCount === 0; setDeleteConfirmation(null); - setDeletingProjects((prev) => new Set([...prev, project.name])); + // Track in-flight deletes by projectId so the UI can disable actions + // even if the project object is rebuilt while the request is flying. + setDeletingProjects((prev) => new Set([...prev, project.projectId])); try { - const response = await api.deleteProject(project.name, !isEmpty, deleteData); + const response = await api.deleteProject(project.projectId, !isEmpty, deleteData); if (response.ok) { - onProjectDelete?.(project.name); + onProjectDelete?.(project.projectId); } else { const error = (await response.json()) as { error?: string }; alert(error.error || t('messages.deleteProjectFailed')); @@ -478,7 +498,7 @@ export function useSidebarController({ } finally { setDeletingProjects((prev) => { const next = new Set(prev); - next.delete(project.name); + next.delete(project.projectId); return next; }); } @@ -486,19 +506,21 @@ export function useSidebarController({ const loadMoreSessions = useCallback( async (project: Project) => { - const hasMoreOverride = projectHasMoreOverrides[project.name]; + // Per-project bookkeeping (additionalSessions, loadingSessions, + // projectHasMoreOverrides) is indexed by the DB `projectId`. + const hasMoreOverride = projectHasMoreOverrides[project.projectId]; const canLoadMore = hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true; - if (!canLoadMore || loadingSessions[project.name]) { + if (!canLoadMore || loadingSessions[project.projectId]) { return; } - setLoadingSessions((prev) => ({ ...prev, [project.name]: true })); + setLoadingSessions((prev) => ({ ...prev, [project.projectId]: true })); try { const currentSessionCount = - (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0); - const response = await api.sessions(project.name, 5, currentSessionCount); + (project.sessions?.length || 0) + (additionalSessions[project.projectId]?.length || 0); + const response = await api.sessions(project.projectId, 5, currentSessionCount); if (!response.ok) { return; @@ -511,17 +533,17 @@ export function useSidebarController({ setAdditionalSessions((prev) => ({ ...prev, - [project.name]: [...(prev[project.name] || []), ...(result.sessions || [])], + [project.projectId]: [...(prev[project.projectId] || []), ...(result.sessions || [])], })); if (result.hasMore === false) { // Keep hasMore state in local hook state instead of mutating the project prop object. - setProjectHasMoreOverrides((prev) => ({ ...prev, [project.name]: false })); + setProjectHasMoreOverrides((prev) => ({ ...prev, [project.projectId]: false })); } } catch (error) { console.error('Error loading more sessions:', error); } finally { - setLoadingSessions((prev) => ({ ...prev, [project.name]: false })); + setLoadingSessions((prev) => ({ ...prev, [project.projectId]: false })); } }, [additionalSessions, loadingSessions, projectHasMoreOverrides], @@ -545,7 +567,9 @@ export function useSidebarController({ }, [onRefresh]); const updateSessionSummary = useCallback( - async (_projectName: string, sessionId: string, summary: string, provider: LLMProvider) => { + // `_projectId` is unused by the rename endpoint but preserved in the + // callback signature so existing wiring from sidebar components works. + async (_projectId: string, sessionId: string, summary: string, provider: LLMProvider) => { const trimmed = summary.trim(); if (!trimmed) { setEditingSession(null); diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 9154717e..891a12ad 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -14,8 +14,10 @@ export type DeleteProjectConfirmation = { sessionCount: number; }; +// Delete confirmation payload; `projectId` is the DB primary key used by the +// DELETE /api/projects/:projectId/sessions/:sessionId endpoint. export type SessionDeleteConfirmation = { - projectName: string; + projectId: string; sessionId: string; sessionTitle: string; provider: LLMProvider; @@ -29,7 +31,9 @@ export type SidebarProps = { onSessionSelect: (session: ProjectSession) => void; onNewSession: (project: Project) => void; onSessionDelete?: (sessionId: string) => void; - onProjectDelete?: (projectName: string) => void; + // `projectId` is the DB identifier; the sidebar hands it back to the parent + // when the delete flow completes. + onProjectDelete?: (projectId: string) => void; isLoading: boolean; loadingProgress: LoadingProgress | null; onRefresh: () => Promise | void; @@ -55,4 +59,11 @@ export type MCPServerStatus = { isConfigured?: boolean; } | null; -export type SettingsProject = Pick; +// Retained as `name` for backwards compatibility with existing settings +// consumers; the value is populated from `projectId` by normalizeProjectForSettings. +export type SettingsProject = { + name: string; + displayName: string; + fullPath: string; + path?: string; +}; diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index a3ec377c..bfadee68 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -102,9 +102,11 @@ export const getAllSessions = ( project: Project, additionalSessions: AdditionalSessionsByProject, ): SessionWithProvider[] => { + // `additionalSessions` is indexed by DB `projectId` now (the sidebar keys + // every per-project map by the same identifier). const claudeSessions = [ ...(project.sessions || []), - ...(additionalSessions[project.name] || []), + ...(additionalSessions[project.projectId] || []), ].map((session) => ({ ...session, __provider: 'claude' as const })); const cursorSessions = (project.cursorSessions || []).map((session) => ({ @@ -151,8 +153,9 @@ export const sortProjects = ( const byName = [...projects]; byName.sort((projectA, projectB) => { - const aStarred = starredProjects.has(projectA.name); - const bStarred = starredProjects.has(projectB.name); + // Starred projects are tracked by `projectId` in localStorage. + const aStarred = starredProjects.has(projectA.projectId); + const bStarred = starredProjects.has(projectB.projectId); if (aStarred && !bStarred) { return -1; @@ -169,7 +172,7 @@ export const sortProjects = ( ); } - return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name); + return (projectA.displayName || projectA.projectId).localeCompare(projectB.displayName || projectB.projectId); }); return byName; @@ -182,9 +185,11 @@ export const filterProjects = (projects: Project[], searchFilter: string): Proje } return projects.filter((project) => { - const displayName = (project.displayName || project.name).toLowerCase(); - const projectName = project.name.toLowerCase(); - return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch); + const displayName = (project.displayName || project.projectId).toLowerCase(); + // `project.path`/`fullPath` is the most useful search target now that the + // folder-derived name is gone; fall back to displayName above. + const searchPath = (project.path || project.fullPath || '').toLowerCase(); + return displayName.includes(normalizedSearch) || searchPath.includes(normalizedSearch); }); }; @@ -218,12 +223,14 @@ export const normalizeProjectForSettings = (project: Project): SettingsProject = ? project.path : ''; + // Legacy SettingsProject still expects a `name` field; use the projectId so + // downstream consumers that rely on a stable identifier continue to work. return { - name: project.name, + name: project.projectId, displayName: typeof project.displayName === 'string' && project.displayName.trim().length > 0 ? project.displayName - : project.name, + : project.projectId, fullPath: fallbackPath, path: typeof project.path === 'string' && project.path.length > 0 diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 0d6c9061..a2853886 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -234,14 +234,18 @@ function Sidebar({ conversationResults={conversationResults} isSearching={isSearching} searchProgress={searchProgress} - onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { + onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { + // `projectId` (DB key) is the canonical identifier post-migration. + // The server emits null when it can't resolve a project row for + // the search hit; treat that as "no project" and still navigate + // to the session so the user can open it from the URL. const resolvedProvider = (provider || 'claude') as LLMProvider; - const project = projects.find(p => p.name === projectName); + const project = projectId ? projects.find(p => p.projectId === projectId) : null; const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null }; const sessionObj = { id: sessionId, __provider: resolvedProvider, - __projectName: projectName, + __projectId: projectId ?? undefined, ...searchTarget, }; if (project) { @@ -249,12 +253,12 @@ function Sidebar({ const sessions = getProjectSessions(project); const existing = sessions.find(s => s.id === sessionId); if (existing) { - handleSessionClick({ ...existing, ...searchTarget }, projectName); + handleSessionClick({ ...existing, ...searchTarget }, project.projectId); } else { - handleSessionClick(sessionObj, projectName); + handleSessionClick(sessionObj, project.projectId); } } else { - handleSessionClick(sessionObj, projectName); + handleSessionClick(sessionObj, projectId ?? ''); } }} onRefresh={() => { diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 250a04d8..3e675f2d 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -48,7 +48,9 @@ type SidebarContentProps = { conversationResults: ConversationSearchResults | null; isSearching: boolean; searchProgress: SearchProgress | null; - onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; + // Conversation result clicks pass back the DB projectId (or null when the + // server couldn't resolve it). Consumers must handle the null case. + onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; @@ -170,10 +172,12 @@ export default function SidebarContent({ {projectResult.sessions.map((session) => (