From dc5d73936a881ffbcae1b95dbdcf13319e4ccafd Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:12:10 +0300 Subject: [PATCH] refactor(projects): identify projects by DB projectId instead of folder-derived name GET /api/projects used to scan ~/.claude/projects/ on every request, derive each project's identity from the encoded folder name, and re-parse JSONL files to build session lists. Using the folder-derived name as the project identifier leaked the Claude CLI's on-disk encoding into every API route, forced every downstream endpoint to re-resolve a real path via JSONL 'cwd' inspection, and made the project list endpoint O(projects x sessions) on disk I/O. This change switches the entire API surface to identify projects by the stable primary key from the 'projects' table and drives the listing straight from the DB: - Add projectsDb.getProjectPathById as the canonical projectId -> path resolver so routes no longer need to touch the filesystem to figure out where a project lives. - Rewrite getProjects so it reads the project list from the 'projects' table and the per-project session list from the 'sessions' table (one SELECT per project). No filesystem scanning happens for this endpoint anymore, which removes the dependency on ~/.claude/projects existing, on Cursor's MD5-hashed chat folders being discoverable, and on Codex's JSONL history being on disk. Per the migration spec each session now exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0 (message counting is not implemented), and sessionMeta.hasMore is pinned to false since this endpoint doesn't drive session pagination. - Introduce id-based wrappers (getSessionsById, renameProjectById, deleteSessionById, deleteProjectById, getProjectTaskMasterById) so every caller can pass projectId and resolve the real path through the DB. renameProjectById also writes to projects.custom_project_name so the DB-driven getProjects response reflects renames immediately; it keeps project-config.json in sync for any legacy reader that still consults the JSON file. - Migrate every /api/projects/:projectName route in server/index.js, server/routes/taskmaster.js, and server/routes/messages.js to :projectId, and change server/routes/git.js so the 'project' query/body parameter carries a projectId that is resolved through the DB before any git command runs. TaskMaster WebSocket broadcasts emit 'projectId' for the same reason so the frontend can match notifications against its current selection without another lookup. - Delete helpers that existed only to feed the old getProjects path (getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along with their unused imports (better-sqlite3's Database, applyCustomSessionNames). The legacy folder-name helpers (getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory) are kept as internal implementation details of the id-based wrappers and of destructive cleanup / conversation search, but they are no longer re-exported. - searchConversations still walks JSONL to produce match snippets (that data doesn't live in the DB), but it now includes the resolved projectId in each result so the sidebar can cross-reference hits with its already loaded project list without a second round-trip. Frontend migration: - Project.name is replaced by Project.projectId in src/types/app.ts, and ProjectSession.__projectName becomes __projectId so session tagging and sidebar state keys stay aligned with the backend identifier. Settings continues to use SettingsProject.name for legacy consumers, but it is populated from projectId by normalizeProjectForSettings. - All places that previously indexed per-project state by project.name (sidebar expanded/starred/loading/deletingProjects sets, additionalSessions map, projectHasMoreOverrides, starredProjects localStorage, command history and draft-input localStorage, TaskMaster caches) now key on projectId so state survives display-name edits and is consistent across the app. - src/utils/api.js renames every endpoint parameter to projectId, the unified messages endpoint takes projectId in its query string, and useSessionStore forwards projectId on fetchFromServer / fetchMore / refreshFromServer. Git panel, file tree, code editor, PRD editor, plugins context, MCP server flows and TaskMaster hooks are all updated to pass projectId. - DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default' projectId sentinel so the empty-shell placeholder still satisfies the Project contract. Bug fix bundled in: - sessionsDb.setName no longer bumps updated_at when a row already exists. Renaming is a label change, not activity, so there is no reason for it to reset 'last activity' in the sidebar. It also no longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive 'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and caused renamed sessions to appear shifted backwards by the client's UTC offset. When an INSERT actually happens it now writes ISO-8601 UTC with a 'Z' suffix. - buildSessionsByProviderFromDb normalizes any legacy naive timestamps in the sessions table to ISO-8601 UTC on the way out so rows written before this change also render correctly on the client. Other cleanup: - Removed the filesystem-first project-discovery comment block at the top of server/projects.js and replaced it with a short note that describes the new DB-driven flow and lists the few remaining filesystem-dependent helpers (message reads, search, destructive delete, manual project registration). - server/modules/providers/index.ts is added as a small barrel so the providers module exposes a stable public surface. Made-with: Cursor --- server/index.js | 156 ++-- .../database/repositories/projects.db.ts | 19 + .../database/repositories/sessions.db.ts | 22 +- server/modules/providers/index.ts | 4 + server/projects.js | 747 +++++++----------- server/routes/git.js | 61 +- server/routes/messages.js | 25 +- server/routes/taskmaster.js | 219 +++-- server/utils/taskmaster-websocket.js | 32 +- .../chat/hooks/useChatComposerState.ts | 20 +- .../chat/hooks/useChatSessionState.ts | 18 +- src/components/chat/hooks/useFileMentions.tsx | 10 +- src/components/chat/hooks/useSlashCommands.ts | 8 +- src/components/chat/view/ChatInterface.tsx | 3 +- .../hooks/useCodeEditorDocument.ts | 17 +- .../code-editor/hooks/useEditorSidebar.ts | 6 +- src/components/code-editor/types/types.ts | 4 +- .../file-tree/hooks/useFileTreeData.ts | 10 +- .../file-tree/hooks/useFileTreeOperations.ts | 10 +- .../file-tree/hooks/useFileTreeUpload.ts | 3 +- src/components/file-tree/types/types.ts | 3 +- src/components/file-tree/view/FileTree.tsx | 4 +- src/components/file-tree/view/ImageViewer.tsx | 2 +- .../git-panel/hooks/useGitPanelController.ts | 56 +- .../git-panel/hooks/useRevertLocalCommit.ts | 12 +- src/components/git-panel/view/GitPanel.tsx | 4 +- .../main-content/view/MainContent.tsx | 10 +- src/components/mcp/hooks/useMcpServerForm.ts | 3 +- src/components/mcp/hooks/useMcpServers.ts | 10 +- src/components/mcp/types.ts | 4 +- .../plugins/view/PluginTabContent.tsx | 5 +- src/components/prd-editor/PRDEditor.tsx | 6 +- .../prd-editor/hooks/usePrdDocument.ts | 5 +- .../prd-editor/hooks/usePrdRegistry.ts | 11 +- src/components/prd-editor/hooks/usePrdSave.ts | 11 +- src/components/prd-editor/types.ts | 3 +- .../sections/AgentCategoryContentSection.tsx | 10 +- .../sidebar/hooks/useSidebarController.ts | 94 ++- src/components/sidebar/types/types.ts | 17 +- src/components/sidebar/utils/utils.ts | 25 +- src/components/sidebar/view/Sidebar.tsx | 16 +- .../view/subcomponents/SidebarContent.tsx | 10 +- .../view/subcomponents/SidebarModals.tsx | 2 +- .../view/subcomponents/SidebarProjectItem.tsx | 14 +- .../view/subcomponents/SidebarProjectList.tsx | 14 +- .../view/subcomponents/SidebarSessionItem.tsx | 10 +- .../task-master/context/TaskMasterContext.tsx | 72 +- .../task-master/hooks/useProjectPrdFiles.ts | 11 +- src/components/task-master/types.ts | 3 +- src/components/task-master/view/TaskBoard.tsx | 5 +- .../task-master/view/TaskMasterPanel.tsx | 2 +- src/constants/config.ts | 10 +- src/hooks/useProjectsState.ts | 69 +- src/stores/useSessionStore.ts | 22 +- src/types/app.ts | 10 +- src/utils/api.js | 84 +- 56 files changed, 1069 insertions(+), 974 deletions(-) create mode 100644 server/modules/providers/index.ts 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) => (