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
This commit is contained in:
Haileyesus
2026-04-24 18:12:10 +03:00
parent 4bd07c3ece
commit dc5d73936a
56 changed files with 1069 additions and 974 deletions

View File

@@ -28,7 +28,17 @@ import { spawn } from 'child_process';
import pty from 'node-pty'; import pty from 'node-pty';
import mime from 'mime-types'; 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 { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const taskMasterDetails = await getProjectTaskMaster(projectName); const taskMasterDetails = await getProjectTaskMasterById(projectId);
if (!taskMasterDetails) {
return res.status(404).json({ error: 'Project not found' });
}
res.json(taskMasterDetails); res.json(taskMasterDetails);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); 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 { try {
const { limit = 5, offset = 0 } = req.query; 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'); applyCustomSessionNames(result.sessions, 'claude');
res.json(result); res.json(result);
} catch (error) { } catch (error) {
@@ -449,23 +464,23 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
} }
}); });
// Rename project endpoint // Rename project endpoint; stores the custom name on the DB row for `projectId`.
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => { app.put('/api/projects/:projectId/rename', authenticateToken, async (req, res) => {
try { try {
const { displayName } = req.body; const { displayName } = req.body;
await renameProject(req.params.projectName, displayName); await renameProjectById(req.params.projectId, displayName);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
// Delete session endpoint // Delete session endpoint; resolves `projectId` to path before touching disk.
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { app.delete('/api/projects/:projectId/sessions/:sessionId', authenticateToken, async (req, res) => {
try { try {
const { projectName, sessionId } = req.params; const { projectId, sessionId } = req.params;
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); console.log(`[API] Deleting session: ${sessionId} from project: ${projectId}`);
await deleteSession(projectName, sessionId); await deleteSessionById(projectId, sessionId);
sessionsDb.deleteName(sessionId, 'claude'); sessionsDb.deleteName(sessionId, 'claude');
console.log(`[API] Session ${sessionId} deleted successfully`); console.log(`[API] Session ${sessionId} deleted successfully`);
res.json({ success: true }); res.json({ success: true });
@@ -504,12 +519,13 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
// Delete project endpoint // Delete project endpoint
// force=true to allow removal even when sessions exist // force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive) // 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const force = req.query.force === 'true'; const force = req.query.force === 'true';
const deleteData = req.query.deleteData === 'true'; const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData); await deleteProjectById(projectId, force, deleteData);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -694,9 +710,9 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => {
}); });
// Read file content endpoint // 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const { filePath } = req.query; 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' }); 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) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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. // 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const { path: filePath } = req.query; 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' }); 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) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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 // 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const { filePath, content } = req.body; 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' }); 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) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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 { try {
// Using fsPromises from import // Using fsPromises from import
// Use extractProjectDirectory to get the actual project path // Resolve the project's absolute path through the DB (projectId is the
let actualPath; // primary key of the `projects` table after the identifier migration).
try { const actualPath = await getProjectPathById(req.params.projectId);
actualPath = await extractProjectDirectory(req.params.projectName); if (!actualPath) {
} catch (error) { return res.status(404).json({ error: 'Project not found' });
console.error('Error extracting project directory:', error);
// Fallback to simple dash replacement
actualPath = req.params.projectName.replace(/-/g, '/');
} }
// Check if path exists // Check if path exists
@@ -917,10 +934,10 @@ function validateFilename(name) {
return { valid: true }; return { valid: true };
} }
// POST /api/projects/:projectName/files/create - Create new file or directory // POST /api/projects/:projectId/files/create - Create new file or directory
app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => { app.post('/api/projects/:projectId/files/create', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { path: parentPath, type, name } = req.body; const { path: parentPath, type, name } = req.body;
// Validate input // Validate input
@@ -937,8 +954,8 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, async (re
return res.status(400).json({ error: nameValidation.error }); return res.status(400).json({ error: nameValidation.error });
} }
// Get project root // Resolve the project directory through the DB using the new projectId.
const projectRoot = await extractProjectDirectory(projectName).catch(() => null); const projectRoot = await getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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 // PUT /api/projects/:projectId/files/rename - Rename file or directory
app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => { app.put('/api/projects/:projectId/files/rename', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { oldPath, newName } = req.body; const { oldPath, newName } = req.body;
// Validate input // Validate input
@@ -1010,8 +1027,8 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req
return res.status(400).json({ error: nameValidation.error }); return res.status(400).json({ error: nameValidation.error });
} }
// Get project root // Resolve the project directory through the DB using the new projectId.
const projectRoot = await extractProjectDirectory(projectName).catch(() => null); const projectRoot = await getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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 // DELETE /api/projects/:projectId/files - Delete file or directory
app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => { app.delete('/api/projects/:projectId/files', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { path: targetPath, type } = req.body; const { path: targetPath, type } = req.body;
// Validate input // 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' }); return res.status(400).json({ error: 'Path is required' });
} }
// Get project root // Resolve the project directory through the DB using the new projectId.
const projectRoot = await extractProjectDirectory(projectName).catch(() => null); const projectRoot = await getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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 // Dynamic import of multer for file uploads
const uploadFilesHandler = async (req, res) => { const uploadFilesHandler = async (req, res) => {
// Dynamic import of multer // Dynamic import of multer
@@ -1175,7 +1192,7 @@ const uploadFilesHandler = async (req, res) => {
} }
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { targetPath, relativePaths } = req.body; const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads) // Parse relative paths if provided (for folder uploads)
@@ -1189,7 +1206,7 @@ const uploadFilesHandler = async (req, res) => {
} }
console.log('[DEBUG] File upload request:', { console.log('[DEBUG] File upload request:', {
projectName, projectId,
targetPath: JSON.stringify(targetPath), targetPath: JSON.stringify(targetPath),
targetPathType: typeof targetPath, targetPathType: typeof targetPath,
filesCount: req.files?.length, filesCount: req.files?.length,
@@ -1200,8 +1217,8 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' }); return res.status(400).json({ error: 'No files provided' });
} }
// Get project root // Resolve the project directory through the DB using the new projectId.
const projectRoot = await extractProjectDirectory(projectName).catch(() => null); const projectRoot = await getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
return res.status(404).json({ error: 'Project not found' }); 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. * 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); console.error('[ERROR] Shell WebSocket error:', error);
}); });
} }
// Image upload endpoint // Image upload endpoint. Accepts the DB-assigned `projectId` (not a folder name)
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => { // 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 { try {
const multer = (await import('multer')).default; const multer = (await import('multer')).default;
const path = (await import('path')).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 // Get token usage for a specific session. `projectId` is the DB primary key;
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { // 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 { try {
const { projectName, sessionId } = req.params; const { projectId, sessionId } = req.params;
const { provider = 'claude' } = req.query; const { provider = 'claude' } = req.query;
const homeDir = os.homedir(); const homeDir = os.homedir();
@@ -2097,13 +2117,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
} }
// Handle Claude sessions (default) // Handle Claude sessions (default)
// Extract actual project path // Resolve the project path through the DB using the caller-supplied
let projectPath; // `projectId`. Legacy code here called extractProjectDirectory with a
try { // folder-encoded project name; the migration centralizes that lookup
projectPath = await extractProjectDirectory(projectName); // in the projects table.
} catch (error) { const projectPath = await getProjectPathById(projectId);
console.error('Error extracting project directory:', error); if (!projectPath) {
return res.status(500).json({ error: 'Failed to determine project path' }); return res.status(404).json({ error: 'Project not found' });
} }
// Construct the JSONL file path // Construct the JSONL file path

View File

@@ -47,6 +47,25 @@ export const projectsDb = {
return row ?? null; 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<ProjectRow, 'project_path'> | undefined;
return row?.project_path ?? null;
},
getProjectPaths(): ProjectRow[] { getProjectPaths(): ProjectRow[] {
const db = getConnection(); const db = getConnection();
return db.prepare(` return db.prepare(`

View File

@@ -192,17 +192,29 @@ export const sessionsDb = {
/** /**
* Legacy-compatibility method kept for parity with `server/database/db.js`. * 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. * TODO: Remove after all legacy imports are migrated to the new repository API.
*/ */
setName(sessionId: string, provider: string, customName: string): void { setName(sessionId: string, provider: string, customName: string): void {
const db = getConnection(); const db = getConnection();
const nowIso = new Date().toISOString();
db.prepare( db.prepare(
`INSERT INTO sessions (session_id, provider, custom_name) `INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, provider) DO UPDATE SET ON CONFLICT(session_id, provider) DO UPDATE SET
custom_name = excluded.custom_name, custom_name = excluded.custom_name`
updated_at = CURRENT_TIMESTAMP` ).run(sessionId, provider, customName, nowIso, nowIso);
).run(sessionId, provider, customName);
}, },
/** /**

View File

@@ -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';

View File

@@ -1,71 +1,39 @@
/** /**
* PROJECT DISCOVERY AND MANAGEMENT SYSTEM * PROJECT DISCOVERY AND MANAGEMENT
* ======================================== * ================================
* *
* This module manages project discovery for both Claude CLI and Cursor CLI sessions. * After the projectName → projectId migration, project and session listings
* * for `GET /api/projects` are sourced entirely from the database:
* ## Architecture Overview *
* * - `projects` table (via `projectsDb`) — the canonical list of projects and
* 1. **Claude Projects** (stored in ~/.claude/projects/) * their absolute `project_path`.
* - Each project is a directory named with the project path encoded (/ replaced with -) * - `sessions` table (via `sessionsDb`) — every provider's sessions for a
* - Contains .jsonl files with conversation history including 'cwd' field * given project, keyed by `project_path`.
* - Project metadata stored in ~/.claude/project-config.json *
* * Routes always address a project by its DB `projectId` and resolve the real
* 2. **Cursor Projects** (stored in ~/.cursor/chats/) * directory through `getProjectPathById` before touching disk.
* - Each project directory is named with MD5 hash of the absolute project path *
* - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6... * The filesystem-aware helpers kept in this module serve the remaining
* - Contains session directories with SQLite databases (store.db) * features that still need on-disk data:
* - Project path is NOT stored in the database - only in the MD5 hash * - Session message reads for each provider (Claude/Codex/Gemini) for
* * `GET /api/sessions/:sessionId/messages`.
* ## Project Discovery Strategy * - Conversation search (`searchConversations`) which scans JSONL history.
* * - Destructive project cleanup (`deleteProjectById` -> `deleteProject`)
* 1. **Claude Projects Discovery**: * which removes Claude/Cursor/Codex artifacts on disk.
* - Scan ~/.claude/projects/ directory for Claude project folders * - Manual project registration (`addProjectManually`) which syncs to
* - Extract actual project path from .jsonl files (cwd field) * ~/.claude/project-config.json for backwards compatibility.
* - 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
*/ */
import { promises as fs } from 'fs'; import fsSync, { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path'; import path from 'path';
import readline from 'readline'; import readline from 'readline';
import crypto from 'crypto'; import crypto from 'crypto';
import Database from 'better-sqlite3';
import os from 'os'; import os from 'os';
import { sessionSynchronizerService } from '@/modules/providers';
import sessionManager from './sessionManager.js'; 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'; import { getModuleDir, findAppRoot } from './utils/runtime-paths.js';
// Snapshot files are kept as incrementing artifacts under .tmp/project-dumps for later review. // 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/<encoded-path>/`. 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); const taskMasterResult = await detectTaskMasterFolder(projectPath);
return { return {
projectName, projectId,
projectPath, projectPath,
taskmaster: normalizeTaskMasterInfo(taskMasterResult) taskmaster: normalizeTaskMasterInfo(taskMasterResult)
}; };
@@ -342,8 +354,10 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
return projectPath; return projectPath;
} }
// Extract the actual project directory from JSONL sessions (with caching) // Resolve a Claude-encoded folder name back to an absolute project directory
// TODO: Get the project id as parameter and return the actual project directory from the database // 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) { async function extractProjectDirectory(projectName) {
// Check cache first // Check cache first
if (projectDirectoryCache.has(projectName)) { 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) { async function getProjects(progressCallback = null) {
const claudeDir = path.join(os.homedir(), '.claude', 'projects'); await sessionSynchronizerService.synchronizeSessions();
const config = await loadProjectConfig(); // 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 projects = [];
const existingProjects = new Set();
const codexSessionsIndexRef = { sessionsByProject: null };
let totalProjects = 0;
let processedProjects = 0; let processedProjects = 0;
let directories = [];
try { for (const row of projectRows) {
// Check if the .claude/projects directory exists processedProjects++;
await fs.access(claudeDir);
// First, get existing Claude projects from the file system const projectId = row.project_id;
const entries = await fs.readdir(claudeDir, { withFileTypes: true }); const projectPath = row.project_path;
directories = entries.filter(e => e.isDirectory());
// Build set of existing project names for later if (progressCallback) {
directories.forEach(e => existingProjects.add(e.name)); progressCallback({
phase: 'loading',
// Count manual projects not already in directories current: processedProjects,
const manualProjectsCount = Object.entries(config) total: totalProjects,
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) currentProject: projectPath
.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);
} }
} catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects // Use the stored custom name when present, otherwise fall back to a
if (error.code !== 'ENOENT') { // generated display name derived from the project path.
console.error('Error reading projects directory:', error); const displayName = row.custom_project_name && row.custom_project_name.trim().length > 0
} ? row.custom_project_name
// Calculate total for manual projects only (no directories exist) : await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
totalProjects = Object.entries(config)
.filter(([name, cfg]) => cfg.manuallyAdded) // All provider session lists are built from a single DB query — no JSONL
.length; // 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) { if (progressCallback) {
progressCallback({ progressCallback({
phase: 'complete', 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/<encoded-path>/; 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 // Rename a project's display name
async function renameProject(projectName, newDisplayName) { async function renameProject(projectName, newDisplayName) {
const config = await loadProjectConfig(); const config = await loadProjectConfig();
@@ -1138,6 +1078,53 @@ async function renameProject(projectName, newDisplayName) {
return true; 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 // Delete a session from a project
async function deleteSession(projectName, sessionId) { async function deleteSession(projectName, sessionId) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); 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) // Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) { async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath); 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) { function normalizeComparablePath(inputPath) {
if (!inputPath || typeof inputPath !== 'string') { if (!inputPath || typeof inputPath !== 'string') {
return ''; return '';
@@ -2011,7 +1923,23 @@ async function searchConversations(query, limit = 50, onProjectResult = null, si
file => file.endsWith('.jsonl') && !file.startsWith('agent-') 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 = { const projectResult = {
projectId: searchProjectId,
projectName, projectName,
projectDisplayName: displayName, projectDisplayName: displayName,
sessions: [] 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) { async function getGeminiCliSessionMessages(sessionId) {
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
let projectDirs; let projectDirs;
@@ -2568,20 +2420,23 @@ async function getGeminiCliSessionMessages(sessionId) {
return []; 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 { export {
getProjects, getProjects,
getSessions, getSessionsById,
getSessionMessages, getSessionMessages,
renameProject, renameProjectById,
deleteSession, deleteSessionById,
deleteProject, deleteProjectById,
addProjectManually, addProjectManually,
getProjectTaskMaster, getProjectTaskMasterById,
extractProjectDirectory, getProjectPathById,
claudeFolderNameFromPath,
clearProjectDirectoryCache, clearProjectDirectoryCache,
getCodexSessionMessages, getCodexSessionMessages,
deleteCodexSession, deleteCodexSession,
getGeminiCliSessions,
getGeminiCliSessionMessages, getGeminiCliSessionMessages,
searchConversations searchConversations
}; };

View File

@@ -2,7 +2,7 @@ import express from 'express';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import path from 'path'; import path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js'; import { getProjectPathById } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js'; import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js'; import { spawnCursor } from '../cursor-cli.js';
@@ -101,14 +101,19 @@ function validateProjectPath(projectPath) {
return resolved; return resolved;
} }
// Helper function to get the actual project path from the encoded project name /**
async function getActualProjectPath(projectName) { * Resolve the absolute project directory for a given DB `projectId`.
let projectPath; *
try { * After the projectName → projectId migration, every git endpoint receives
projectPath = await extractProjectDirectory(projectName); * the DB primary key (`project` query/body param). The legacy filesystem
} catch (error) { * resolver that walked Claude's JSONL history is no longer used here; the
console.error(`Error extracting project directory for ${projectName}:`, error); * path comes straight from the `projects` table and is then sanity-checked
throw new Error(`Unable to resolve project path for "${projectName}"`); * 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); return validateProjectPath(projectPath);
} }
@@ -292,7 +297,7 @@ router.get('/status', async (req, res) => {
const { project } = req.query; const { project } = req.query;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -355,7 +360,7 @@ router.get('/diff', async (req, res) => {
const { project, file } = req.query; const { project, file } = req.query;
if (!project || !file) { 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 { try {
@@ -438,7 +443,7 @@ router.get('/file-with-diff', async (req, res) => {
const { project, file } = req.query; const { project, file } = req.query;
if (!project || !file) { 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 { try {
@@ -518,7 +523,7 @@ router.post('/initial-commit', async (req, res) => {
const { project } = req.body; const { project } = req.body;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -593,7 +598,7 @@ router.post('/revert-local-commit', async (req, res) => {
const { project } = req.body; const { project } = req.body;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -640,7 +645,7 @@ router.get('/branches', async (req, res) => {
const { project } = req.query; const { project } = req.query;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -684,7 +689,7 @@ router.post('/checkout', async (req, res) => {
const { project, branch } = req.body; const { project, branch } = req.body;
if (!project || !branch) { 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 { try {
@@ -706,7 +711,7 @@ router.post('/create-branch', async (req, res) => {
const { project, branch } = req.body; const { project, branch } = req.body;
if (!project || !branch) { 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 { try {
@@ -728,7 +733,7 @@ router.post('/delete-branch', async (req, res) => {
const { project, branch } = req.body; const { project, branch } = req.body;
if (!project || !branch) { 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 { try {
@@ -754,7 +759,7 @@ router.get('/commits', async (req, res) => {
const { project, limit = 10 } = req.query; const { project, limit = 10 } = req.query;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -811,7 +816,7 @@ router.get('/commit-diff', async (req, res) => {
const { project, commit } = req.query; const { project, commit } = req.query;
if (!project || !commit) { 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 { try {
@@ -843,7 +848,7 @@ router.post('/generate-commit-message', async (req, res) => {
const { project, files, provider = 'claude' } = req.body; const { project, files, provider = 'claude' } = req.body;
if (!project || !files || files.length === 0) { 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 // Validate provider
@@ -1048,7 +1053,7 @@ router.get('/remote-status', async (req, res) => {
const { project } = req.query; const { project } = req.query;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -1126,7 +1131,7 @@ router.post('/fetch', async (req, res) => {
const { project } = req.body; const { project } = req.body;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -1167,7 +1172,7 @@ router.post('/pull', async (req, res) => {
const { project } = req.body; const { project } = req.body;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -1235,7 +1240,7 @@ router.post('/push', async (req, res) => {
const { project } = req.body; const { project } = req.body;
if (!project) { if (!project) {
return res.status(400).json({ error: 'Project name is required' }); return res.status(400).json({ error: 'Project id is required' });
} }
try { try {
@@ -1306,7 +1311,7 @@ router.post('/publish', async (req, res) => {
const { project, branch } = req.body; const { project, branch } = req.body;
if (!project || !branch) { 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 { try {
@@ -1385,7 +1390,7 @@ router.post('/discard', async (req, res) => {
const { project, file } = req.body; const { project, file } = req.body;
if (!project || !file) { 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 { try {
@@ -1439,7 +1444,7 @@ router.post('/delete-untracked', async (req, res) => {
const { project, file } = req.body; const { project, file } = req.body;
if (!project || !file) { 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 { try {

View File

@@ -1,16 +1,21 @@
/** /**
* Unified messages endpoint. * Unified messages endpoint.
* *
* GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0 * GET /api/sessions/:sessionId/messages?provider=claude&projectId=<id>&limit=50&offset=0
* *
* Replaces the four provider-specific session message endpoints with a single route * Replaces the four provider-specific session message endpoints with a single route
* that delegates to the appropriate adapter via the provider registry. * 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 * @module routes/messages
*/ */
import express from 'express'; import express from 'express';
import { sessionsService } from '../modules/providers/services/sessions.service.js'; import { sessionsService } from '../modules/providers/services/sessions.service.js';
import { getProjectPathById, claudeFolderNameFromPath } from '../projects.js';
const router = express.Router(); const router = express.Router();
@@ -21,7 +26,7 @@ const router = express.Router();
* *
* Query params: * Query params:
* provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude') * 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) * projectPath - required for cursor provider (absolute path used for cwdId hash)
* limit - page size (omit or null for all) * limit - page size (omit or null for all)
* offset - pagination offset (default: 0) * offset - pagination offset (default: 0)
@@ -30,7 +35,7 @@ router.get('/:sessionId/messages', async (req, res) => {
try { try {
const { sessionId } = req.params; const { sessionId } = req.params;
const provider = String(req.query.provider || 'claude').trim().toLowerCase(); 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 projectPath = req.query.projectPath || '';
const limitParam = req.query.limit; const limitParam = req.query.limit;
const limit = limitParam !== undefined && limitParam !== null && limitParam !== '' 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}` }); return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
} }
// The Claude adapter still reads sessions from ~/.claude/projects/<folder>/,
// 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, { const result = await sessionsService.fetchHistory(provider, sessionId, {
projectName, projectName: claudeProjectName,
projectPath, projectPath,
limit, limit,
offset, offset,

View File

@@ -13,10 +13,25 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { promises as fsPromises } from 'fs'; import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { extractProjectDirectory } from '../projects.js'; import { getProjectPathById } from '../projects.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.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(); 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 * 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
// Get project path // Get project path via the DB; the legacy JSONL-based resolver is gone.
let projectPath; const projectPath = await resolveProjectPathFromId(projectId);
try { if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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); await fsPromises.access(tasksFilePath);
} catch (error) { } catch (error) {
return res.json({ return res.json({
projectName, projectId,
tasks: [], tasks: [],
message: 'No tasks.json file found' message: 'No tasks.json file found'
}); });
@@ -213,7 +229,7 @@ router.get('/tasks/:projectName', async (req, res) => {
})); }));
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
tasks: transformedTasks, tasks: transformedTasks,
currentTag, 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 * 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
// Get project path // projectId → projectPath lookup through the DB (post-migration).
let projectPath; const projectPath = await resolveProjectPathFromId(projectId);
try { if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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); await fsPromises.access(docsPath, fs.constants.R_OK);
} catch (error) { } catch (error) {
return res.json({ return res.json({
projectName, projectId,
prdFiles: [], prdFiles: [],
message: 'No .taskmaster/docs directory found' message: 'No .taskmaster/docs directory found'
}); });
@@ -299,7 +313,7 @@ router.get('/prd/:projectName', async (req, res) => {
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)), prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)),
timestamp: new Date().toISOString() 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 * 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const { fileName, content } = req.body; const { fileName, content } = req.body;
if (!fileName || !content) { if (!fileName || !content) {
@@ -346,14 +360,12 @@ router.post('/prd/:projectName', async (req, res) => {
}); });
} }
// Get project path // Resolve the project folder through the DB using the projectId param.
let projectPath; const projectPath = await resolveProjectPathFromId(projectId);
try { if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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); const stats = await fsPromises.stat(filePath);
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
fileName, fileName,
filePath: path.relative(projectPath, filePath), 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 * Get content of a specific PRD file
*/ */
router.get('/prd/:projectName/:fileName', async (req, res) => { router.get('/prd/:projectId/:fileName', async (req, res) => {
try { try {
const { projectName, fileName } = req.params; const { projectId, fileName } = req.params;
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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); const stats = await fsPromises.stat(filePath);
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
fileName, fileName,
filePath: path.relative(projectPath, filePath), 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 * Initialize TaskMaster in a project
*/ */
router.post('/init/:projectName', async (req, res) => { router.post('/init/:projectId', async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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) => { initProcess.on('close', (code) => {
if (code === 0) { 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) { if (req.app.locals.wss) {
broadcastTaskMasterProjectUpdate( broadcastTaskMasterProjectUpdate(
req.app.locals.wss, req.app.locals.wss,
projectName, projectId,
{ hasTaskmaster: true, status: 'initialized' } { hasTaskmaster: true, status: 'initialized' }
); );
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
message: 'TaskMaster initialized successfully', message: 'TaskMaster initialized successfully',
output: stdout, 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 * Add a new task to the project
*/ */
router.post('/add-task/:projectName', async (req, res) => { router.post('/add-task/:projectId', async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { prompt, title, description, priority = 'medium', dependencies } = req.body; const { prompt, title, description, priority = 'medium', dependencies } = req.body;
if (!prompt && (!title || !description)) { 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' message: 'Either "prompt" or both "title" and "description" are required'
}); });
} }
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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); console.log('Stderr:', stderr);
if (code === 0) { 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) { if (req.app.locals.wss) {
broadcastTaskMasterTasksUpdate( broadcastTaskMasterTasksUpdate(
req.app.locals.wss, req.app.locals.wss,
projectName projectId
); );
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
message: 'Task added successfully', message: 'Task added successfully',
output: stdout, 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 * 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 { try {
const { projectName, taskId } = req.params; const { projectId, taskId } = req.params;
const { title, description, status, priority, details } = req.body; const { title, description, status, priority, details } = req.body;
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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) { if (code === 0) {
// Broadcast task update via WebSocket // Broadcast task update via WebSocket
if (req.app.locals.wss) { if (req.app.locals.wss) {
broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectId);
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
taskId, taskId,
message: 'Task status updated successfully', message: 'Task status updated successfully',
@@ -759,11 +762,11 @@ router.put('/update-task/:projectName/:taskId', async (req, res) => {
if (code === 0) { if (code === 0) {
// Broadcast task update via WebSocket // Broadcast task update via WebSocket
if (req.app.locals.wss) { if (req.app.locals.wss) {
broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectId);
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
taskId, taskId,
message: 'Task updated successfully', 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 * Parse a PRD file to generate tasks
*/ */
router.post('/parse-prd/:projectName', async (req, res) => { router.post('/parse-prd/:projectId', async (req, res) => {
try { try {
const { projectName } = req.params; const { projectId } = req.params;
const { fileName = 'prd.txt', numTasks, append = false } = req.body; const { fileName = 'prd.txt', numTasks, append = false } = req.body;
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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 // Broadcast task update via WebSocket
if (req.app.locals.wss) { if (req.app.locals.wss) {
broadcastTaskMasterTasksUpdate( broadcastTaskMasterTasksUpdate(
req.app.locals.wss, req.app.locals.wss,
projectName projectId
); );
} }
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
prdFile: fileName, prdFile: fileName,
message: 'PRD parsed and tasks generated successfully', 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 * 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 { try {
const { projectName } = req.params; const { projectId } = req.params;
const { templateId, fileName = 'prd.txt', customizations = {} } = req.body; const { templateId, fileName = 'prd.txt', customizations = {} } = req.body;
if (!templateId) { if (!templateId) {
@@ -1355,14 +1355,11 @@ router.post('/apply-template/:projectName', async (req, res) => {
}); });
} }
// Get project path const projectPath = await resolveProjectPathFromId(projectId);
let projectPath; if (!projectPath) {
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({ return res.status(404).json({
error: 'Project not found', 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'); await fsPromises.writeFile(filePath, content, 'utf8');
res.json({ res.json({
projectName, projectId,
projectPath, projectPath,
templateId, templateId,
templateName: template.name, templateName: template.name,

View File

@@ -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 {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 * @param {Object} taskMasterData - Updated TaskMaster data
*/ */
export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) { export function broadcastTaskMasterProjectUpdate(wss, projectId, taskMasterData) {
if (!wss || !projectName) { if (!wss || !projectId) {
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); console.warn('TaskMaster WebSocket broadcast: Missing wss or projectId');
return; return;
} }
const message = { const message = {
type: 'taskmaster-project-updated', type: 'taskmaster-project-updated',
projectName, projectId,
taskMasterData, taskMasterData,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
@@ -38,20 +43,21 @@ export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterDat
} }
/** /**
* Broadcast TaskMaster tasks update for a specific project * 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 * @param {WebSocket.Server} wss - WebSocket server instance
* @param {string} projectId - DB id of the project with updated tasks
* @param {Object} tasksData - Updated tasks data * @param {Object} tasksData - Updated tasks data
*/ */
export function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) { export function broadcastTaskMasterTasksUpdate(wss, projectId, tasksData) {
if (!wss || !projectName) { if (!wss || !projectId) {
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); console.warn('TaskMaster WebSocket broadcast: Missing wss or projectId');
return; return;
} }
const message = { const message = {
type: 'taskmaster-tasks-updated', type: 'taskmaster-tasks-updated',
projectName, projectId,
tasksData, tasksData,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };

View File

@@ -135,7 +135,9 @@ export function useChatComposerState({
}: UseChatComposerStateArgs) { }: UseChatComposerStateArgs) {
const [input, setInput] = useState(() => { const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) { 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 ''; return '';
}); });
@@ -276,9 +278,11 @@ export function useChatComposerState({
const args = const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; 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 = { const context = {
projectPath: selectedProject.fullPath || selectedProject.path, projectPath: selectedProject.fullPath || selectedProject.path,
projectName: selectedProject.name, projectId: selectedProject.projectId,
sessionId: currentSessionId, sessionId: currentSessionId,
provider, provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel, model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
@@ -503,7 +507,7 @@ export function useChatComposerState({
}); });
try { try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, { const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
method: 'POST', method: 'POST',
headers: {}, headers: {},
body: formData, body: formData,
@@ -669,7 +673,7 @@ export function useChatComposerState({
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
} }
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
}, },
[ [
selectedSession, selectedSession,
@@ -712,22 +716,22 @@ export function useChatComposerState({
if (!selectedProject) { if (!selectedProject) {
return; return;
} }
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
setInput((previous) => { setInput((previous) => {
const next = previous === savedInput ? previous : savedInput; const next = previous === savedInput ? previous : savedInput;
inputValueRef.current = next; inputValueRef.current = next;
return next; return next;
}); });
}, [selectedProject?.name]); }, [selectedProject?.projectId]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
return; return;
} }
if (input !== '') { if (input !== '') {
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input);
} else { } else {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
} }
}, [input, selectedProject]); }, [input, selectedProject]);

View File

@@ -241,7 +241,8 @@ export function useChatSessionState({
try { try {
const slot = await sessionStore.fetchMore(selectedSession.id, { const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as LLMProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, // DB-assigned projectId replaces the legacy folder-derived name.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
}); });
@@ -296,7 +297,7 @@ export function useChatSessionState({
topLoadLockRef.current = false; topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null; pendingScrollRestoreRef.current = null;
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
}, [selectedProject?.name, selectedSession?.id]); }, [selectedProject?.projectId, selectedSession?.id]);
// Initial scroll to bottom // Initial scroll to bottom
useEffect(() => { useEffect(() => {
@@ -325,7 +326,7 @@ export function useChatSessionState({
} }
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; 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 // Skip if already loaded and fresh
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
@@ -375,7 +376,7 @@ export function useChatSessionState({
setIsLoadingSessionMessages(true); setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSession.id, { sessionStore.fetchFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider, provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name, projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
offset: 0, offset: 0,
@@ -411,7 +412,7 @@ export function useChatSessionState({
if (!isLoading) { if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as LLMProvider, provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name, projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
}); });
@@ -469,7 +470,7 @@ export function useChatSessionState({
// Load all messages into the store for search navigation // Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, { const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as LLMProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,
offset: 0, offset: 0,
@@ -550,7 +551,8 @@ export function useChatSessionState({
const fetchInitialTokenUsage = async () => { const fetchInitialTokenUsage = async () => {
try { 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); const response = await authenticatedFetch(url);
if (response.ok) { if (response.ok) {
setTokenBudget(await response.json()); setTokenBudget(await response.json());
@@ -656,7 +658,7 @@ export function useChatSessionState({
try { try {
const slot = await sessionStore.fetchFromServer(requestSessionId, { const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as LLMProvider, provider: sessionProvider as LLMProvider,
projectName: selectedProject.name, projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null, limit: null,
offset: 0, offset: 0,

View File

@@ -59,16 +59,18 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
const abortController = new AbortController(); const abortController = new AbortController();
const fetchProjectFiles = async () => { 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([]); setFileList([]);
setFilteredFiles([]); setFilteredFiles([]);
if (!projectName) { if (!projectId) {
return; return;
} }
try { try {
const response = await api.getFiles(projectName, { signal: abortController.signal }); const response = await api.getFiles(projectId, { signal: abortController.signal });
if (!response.ok) { if (!response.ok) {
return; return;
} }
@@ -88,7 +90,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
return () => { return () => {
abortController.abort(); abortController.abort();
}; };
}, [selectedProject?.name]); }, [selectedProject?.projectId]);
useEffect(() => { useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition); const textBeforeCursor = input.slice(0, cursorPosition);

View File

@@ -114,7 +114,7 @@ export function useSlashCommands({
})), })),
]; ];
const parsedHistory = readCommandHistory(selectedProject.name); const parsedHistory = readCommandHistory(selectedProject.projectId);
const sortedCommands = [...allCommands].sort((commandA, commandB) => { const sortedCommands = [...allCommands].sort((commandA, commandB) => {
const commandAUsage = parsedHistory[commandA.name] || 0; const commandAUsage = parsedHistory[commandA.name] || 0;
const commandBUsage = parsedHistory[commandB.name] || 0; const commandBUsage = parsedHistory[commandB.name] || 0;
@@ -173,7 +173,7 @@ export function useSlashCommands({
return []; return [];
} }
const parsedHistory = readCommandHistory(selectedProject.name); const parsedHistory = readCommandHistory(selectedProject.projectId);
return slashCommands return slashCommands
.map((command) => ({ .map((command) => ({
@@ -191,9 +191,9 @@ export function useSlashCommands({
return; return;
} }
const parsedHistory = readCommandHistory(selectedProject.name); const parsedHistory = readCommandHistory(selectedProject.projectId);
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
saveCommandHistory(selectedProject.name, parsedHistory); saveCommandHistory(selectedProject.projectId, parsedHistory);
}, },
[selectedProject], [selectedProject],
); );

View File

@@ -212,7 +212,8 @@ function ChatInterface({
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, { await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as LLMProvider, 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 || '', projectPath: selectedProject.fullPath || selectedProject.path || '',
}); });
setIsLoading(false); setIsLoading(false);

View File

@@ -23,7 +23,10 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false); 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 filePath = file.path;
const fileName = file.name; const fileName = file.name;
const fileDiffNewString = file.diffInfo?.new_string; const fileDiffNewString = file.diffInfo?.new_string;
@@ -49,11 +52,11 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
return; return;
} }
if (!fileProjectName) { if (!fileProjectId) {
throw new Error('Missing project identifier'); throw new Error('Missing project identifier');
} }
const response = await api.readFile(fileProjectName, filePath); const response = await api.readFile(fileProjectId, filePath);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
} }
@@ -70,18 +73,18 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}; };
loadFileContent(); loadFileContent();
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]); }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
if (!fileProjectName) { if (!fileProjectId) {
throw new Error('Missing project identifier'); 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) { if (!response.ok) {
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
@@ -106,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [content, filePath, fileProjectName]); }, [content, filePath, fileProjectId]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });

View File

@@ -29,11 +29,13 @@ export const useEditorSidebar = ({
setEditingFile({ setEditingFile({
name: fileName, name: fileName,
path: filePath, 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, diffInfo,
}); });
}, },
[selectedProject?.name], [selectedProject?.projectId],
); );
const handleCloseEditor = useCallback(() => { const handleCloseEditor = useCallback(() => {

View File

@@ -7,7 +7,9 @@ export type CodeEditorDiffInfo = {
export type CodeEditorFile = { export type CodeEditorFile = {
name: string; name: string;
path: 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; diffInfo?: CodeEditorDiffInfo | null;
[key: string]: unknown; [key: string]: unknown;
}; };

View File

@@ -20,9 +20,11 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
}, []); }, []);
useEffect(() => { 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([]); setFiles([]);
setLoading(false); setLoading(false);
return; return;
@@ -42,7 +44,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
setLoading(true); setLoading(true);
} }
try { try {
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal }); const response = await api.getFiles(projectId, { signal: abortControllerRef.current!.signal });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@@ -79,7 +81,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
isActive = false; isActive = false;
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
}, [selectedProject?.name, refreshKey]); }, [selectedProject?.projectId, refreshKey]);
return { return {
files, files,

View File

@@ -126,7 +126,7 @@ export function useFileTreeOperations({
setOperationLoading(true); setOperationLoading(true);
try { try {
const response = await api.renameFile(selectedProject.name, { const response = await api.renameFile(selectedProject.projectId, {
oldPath: renamingItem.path, oldPath: renamingItem.path,
newName: renameValue, newName: renameValue,
}); });
@@ -161,7 +161,7 @@ export function useFileTreeOperations({
setOperationLoading(true); setOperationLoading(true);
try { try {
const response = await api.deleteFile(selectedProject.name, { const response = await api.deleteFile(selectedProject.projectId, {
path: item.path, path: item.path,
type: item.type, type: item.type,
}); });
@@ -212,7 +212,7 @@ export function useFileTreeOperations({
setOperationLoading(true); setOperationLoading(true);
try { try {
const response = await api.createFile(selectedProject.name, { const response = await api.createFile(selectedProject.projectId, {
path: newItemParent, path: newItemParent,
type: newItemType, type: newItemType,
name: newItemName, name: newItemName,
@@ -287,7 +287,7 @@ export function useFileTreeOperations({
if (!selectedProject) return; if (!selectedProject) return;
// Use the binary streaming endpoint so downloads preserve raw bytes. // 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) { if (!response.ok) {
throw new Error('Failed to download file'); throw new Error('Failed to download file');
@@ -308,7 +308,7 @@ export function useFileTreeOperations({
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name; const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
if (node.type === 'file') { 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) { if (!response.ok) {
throw new Error(`Failed to download "${node.name}" for ZIP export`); throw new Error(`Failed to download "${node.name}" for ZIP export`);
} }

View File

@@ -154,7 +154,8 @@ export const useFileTreeUpload = ({
formData.append('relativePaths', JSON.stringify(relativePaths)); formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post( 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 formData
); );

View File

@@ -19,7 +19,8 @@ export interface FileTreeImageSelection {
name: string; name: string;
path: string; path: string;
projectPath?: string; projectPath?: string;
projectName: string; // DB projectId; used by ImageViewer to build the raw content URL.
projectId: string;
} }
export interface FileIconData { export interface FileIconData {

View File

@@ -101,7 +101,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
name: item.name, name: item.name,
path: item.path, path: item.path,
projectPath: selectedProject.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; return;
} }

View File

@@ -10,7 +10,7 @@ type ImageViewerProps = {
}; };
export default function ImageViewer({ file, onClose }: 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<string | null>(null); const [imageUrl, setImageUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -64,10 +64,12 @@ export function useGitPanelController({
const [operationError, setOperationError] = useState<string | null>(null); const [operationError, setOperationError] = useState<string | null>(null);
const clearOperationError = useCallback(() => setOperationError(null), []); const clearOperationError = useCallback(() => setOperationError(null), []);
const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null); // Tracks the DB projectId so async requests can detect stale responses when
// the user switches projects mid-flight.
const selectedProjectIdRef = useRef<string | null>(selectedProject?.projectId ?? null);
useEffect(() => { useEffect(() => {
selectedProjectNameRef.current = selectedProject?.name ?? null; selectedProjectIdRef.current = selectedProject?.projectId ?? null;
}, [selectedProject]); }, [selectedProject]);
const provider = useSelectedProvider(); const provider = useSelectedProvider();
@@ -78,18 +80,19 @@ export function useGitPanelController({
return; return;
} }
const projectName = selectedProject.name; // Git endpoints receive the DB projectId via the `project` query param.
const projectId = selectedProject.projectId;
try { try {
const response = await fetchWithAuth( const response = await fetchWithAuth(
`/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`, `/api/git/diff?project=${encodeURIComponent(projectId)}&file=${encodeURIComponent(filePath)}`,
{ signal }, { signal },
); );
const data = await readJson<GitDiffResponse>(response, signal); const data = await readJson<GitDiffResponse>(response, signal);
if ( if (
signal?.aborted || signal?.aborted ||
selectedProjectNameRef.current !== projectName selectedProjectIdRef.current !== projectId
) { ) {
return; return;
} }
@@ -116,16 +119,17 @@ export function useGitPanelController({
return; return;
} }
const projectName = selectedProject.name; // `project` query param carries the DB projectId everywhere now.
const projectId = selectedProject.projectId;
setIsLoading(true); setIsLoading(true);
try { 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<GitStatusResponse>(response, signal); const data = await readJson<GitStatusResponse>(response, signal);
if ( if (
signal?.aborted || signal?.aborted ||
selectedProjectNameRef.current !== projectName selectedProjectIdRef.current !== projectId
) { ) {
return; return;
} }
@@ -150,7 +154,7 @@ export function useGitPanelController({
} }
if ( if (
selectedProjectNameRef.current !== projectName selectedProjectIdRef.current !== projectId
) { ) {
return; return;
} }
@@ -169,7 +173,7 @@ export function useGitPanelController({
} }
try { 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<GitBranchesResponse>(response); const data = await readJson<GitBranchesResponse>(response);
if (!data.error && data.branches) { if (!data.error && data.branches) {
@@ -196,7 +200,7 @@ export function useGitPanelController({
} }
try { 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<GitRemoteStatus | GitApiErrorResponse>(response); const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);
if (!data.error) { if (!data.error) {
@@ -222,7 +226,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
branch: branchName, branch: branchName,
}), }),
}); });
@@ -257,7 +261,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
branch: trimmedBranchName, branch: trimmedBranchName,
}), }),
}); });
@@ -290,7 +294,7 @@ export function useGitPanelController({
const response = await fetchWithAuth('/api/git/delete-branch', { const response = await fetchWithAuth('/api/git/delete-branch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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<GitOperationResponse>(response); const data = await readJson<GitOperationResponse>(response);
@@ -320,7 +324,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
}), }),
}); });
@@ -351,7 +355,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
}), }),
}); });
@@ -381,7 +385,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
}), }),
}); });
@@ -411,7 +415,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
branch: currentBranch, branch: currentBranch,
}), }),
}); });
@@ -442,7 +446,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
file: filePath, file: filePath,
}), }),
}); });
@@ -472,7 +476,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
file: filePath, file: filePath,
}), }),
}); });
@@ -498,7 +502,7 @@ export function useGitPanelController({
try { try {
const response = await fetchWithAuth( 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<GitCommitsResponse>(response); const data = await readJson<GitCommitsResponse>(response);
@@ -518,7 +522,7 @@ export function useGitPanelController({
try { try {
const response = await fetchWithAuth( 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<GitDiffResponse>(response); const data = await readJson<GitDiffResponse>(response);
@@ -546,7 +550,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
files, files,
provider, provider,
}), }),
@@ -578,7 +582,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
message, message,
files, files,
}), }),
@@ -612,7 +616,7 @@ export function useGitPanelController({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.projectId,
}), }),
}); });
@@ -645,7 +649,7 @@ export function useGitPanelController({
try { try {
const response = await fetchWithAuth( 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<GitFileWithDiffResponse>(response); const data = await readJson<GitFileWithDiffResponse>(response);

View File

@@ -3,7 +3,9 @@ import { authenticatedFetch } from '../../../utils/api';
import type { GitOperationResponse } from '../types/types'; import type { GitOperationResponse } from '../types/types';
type UseRevertLocalCommitOptions = { 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; onSuccess?: () => void;
}; };
@@ -11,11 +13,11 @@ async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) { export function useRevertLocalCommit({ projectId, onSuccess }: UseRevertLocalCommitOptions) {
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false); const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
const revertLatestLocalCommit = useCallback(async () => { const revertLatestLocalCommit = useCallback(async () => {
if (!projectName) { if (!projectId) {
return; return;
} }
@@ -24,7 +26,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC
const response = await authenticatedFetch('/api/git/revert-local-commit', { const response = await authenticatedFetch('/api/git/revert-local-commit', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: projectName }), body: JSON.stringify({ project: projectId }),
}); });
const data = await readJson<GitOperationResponse>(response); const data = await readJson<GitOperationResponse>(response);
@@ -39,7 +41,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC
} finally { } finally {
setIsRevertingLocalCommit(false); setIsRevertingLocalCommit(false);
} }
}, [onSuccess, projectName]); }, [onSuccess, projectId]);
return { return {
isRevertingLocalCommit, isRevertingLocalCommit,

View File

@@ -58,7 +58,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
}); });
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({ 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, onSuccess: refreshAll,
}); });

View File

@@ -73,13 +73,15 @@ function MainContent({
}); });
useEffect(() => { useEffect(() => {
const selectedProjectName = selectedProject?.name; // Identify projects by DB `projectId`; the TaskMaster context uses the
const currentProjectName = currentProject?.name; // 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); setCurrentProject?.(selectedProject);
} }
}, [selectedProject, currentProject?.name, setCurrentProject]); }, [selectedProject, currentProject?.projectId, setCurrentProject]);
useEffect(() => { useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') { if (!shouldShowTasksTab && activeTab === 'tasks') {

View File

@@ -128,7 +128,8 @@ export function useMcpServerForm({
currentProjects currentProjects
.map((project) => ({ .map((project) => ({
value: getProjectPath(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) .filter((project) => project.value)
), [currentProjects]); ), [currentProjects]);

View File

@@ -31,6 +31,8 @@ type GlobalMcpServerResponse = {
results: GlobalMcpServerResult[]; 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 = { type ProjectTarget = {
name: string; name: string;
displayName: string; displayName: string;
@@ -111,6 +113,9 @@ const normalizeServer = (
bearerTokenEnvVar: server.bearerTokenEnvVar, bearerTokenEnvVar: server.bearerTokenEnvVar,
envHttpHeaders: server.envHttpHeaders ?? {}, envHttpHeaders: server.envHttpHeaders ?? {},
workspacePath: project?.path || server.workspacePath, 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, projectName: project?.name || server.projectName,
projectDisplayName: project?.displayName || server.projectDisplayName, projectDisplayName: project?.displayName || server.projectDisplayName,
}; };
@@ -126,8 +131,9 @@ const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
seen.add(projectPath); seen.add(projectPath);
acc.push({ acc.push({
name: project.name, // Use projectId as the stable internal identifier.
displayName: project.displayName || project.name, name: project.projectId,
displayName: project.displayName || project.projectId,
path: projectPath, path: projectPath,
}); });
return acc; return acc;

View File

@@ -7,8 +7,10 @@ export type McpImportMode = 'form' | 'json';
export type McpFormMode = 'provider' | 'global'; export type McpFormMode = 'provider' | 'global';
export type KeyValueMap = Record<string, string>; export type KeyValueMap = Record<string, string>;
// Internal MCP shape; `projectId` replaces the legacy `name` field from the
// projectName → projectId migration.
export type McpProject = { export type McpProject = {
name: string; projectId: string;
displayName?: string; displayName?: string;
fullPath?: string; fullPath?: string;
path?: string; path?: string;

View File

@@ -12,6 +12,9 @@ type PluginTabContentProps = {
type PluginContext = { type PluginContext = {
theme: 'dark' | 'light'; 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; project: { name: string; path: string } | null;
session: { id: string; title: string } | null; session: { id: string; title: string } | null;
}; };
@@ -25,7 +28,7 @@ function buildContext(
theme: isDarkMode ? 'dark' : 'light', theme: isDarkMode ? 'dark' : 'light',
project: selectedProject project: selectedProject
? { ? {
name: selectedProject.name, name: selectedProject.projectId,
path: selectedProject.fullPath || selectedProject.path || '', path: selectedProject.fullPath || selectedProject.path || '',
} }
: null, : null,

View File

@@ -39,14 +39,16 @@ export default function PRDEditor({
projectPath, projectPath,
}); });
// PRD hooks are now addressed by DB `projectId`; the backend resolves the
// `.taskmaster/docs` folder from the `projects` table.
const { existingPrds, refreshExistingPrds } = usePrdRegistry({ const { existingPrds, refreshExistingPrds } = usePrdRegistry({
projectName: project?.name, projectId: project?.projectId,
}); });
const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]); const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]);
const { savePrd, saving, saveSuccess } = usePrdSave({ const { savePrd, saving, saveSuccess } = usePrdSave({
projectName: project?.name, projectId: project?.projectId,
existingPrds, existingPrds,
isExistingFile, isExistingFile,
onAfterSave: async () => { onAfterSave: async () => {

View File

@@ -73,7 +73,7 @@ export function usePrdDocument({
return; return;
} }
if (!file?.projectName || !file?.path) { if (!file?.projectId || !file?.path) {
if (!isMounted) { if (!isMounted) {
return; return;
} }
@@ -87,7 +87,8 @@ export function usePrdDocument({
try { try {
setLoading(true); 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) { if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
} }

View File

@@ -3,7 +3,8 @@ import { api } from '../../../utils/api';
import type { ExistingPrdFile, PrdListResponse } from '../types'; import type { ExistingPrdFile, PrdListResponse } from '../types';
type UsePrdRegistryArgs = { type UsePrdRegistryArgs = {
projectName?: string; // DB primary key of the project (post migration).
projectId?: string;
}; };
type UsePrdRegistryResult = { type UsePrdRegistryResult = {
@@ -15,17 +16,17 @@ function getPrdFiles(data: PrdListResponse): ExistingPrdFile[] {
return data.prdFiles || data.prds || []; return data.prdFiles || data.prds || [];
} }
export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegistryResult { export function usePrdRegistry({ projectId }: UsePrdRegistryArgs): UsePrdRegistryResult {
const [existingPrds, setExistingPrds] = useState<ExistingPrdFile[]>([]); const [existingPrds, setExistingPrds] = useState<ExistingPrdFile[]>([]);
const refreshExistingPrds = useCallback(async () => { const refreshExistingPrds = useCallback(async () => {
if (!projectName) { if (!projectId) {
setExistingPrds([]); setExistingPrds([]);
return; return;
} }
try { try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`); const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectId)}`);
if (!response.ok) { if (!response.ok) {
setExistingPrds([]); setExistingPrds([]);
return; return;
@@ -37,7 +38,7 @@ export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegis
console.error('Failed to fetch existing PRDs:', error); console.error('Failed to fetch existing PRDs:', error);
setExistingPrds([]); setExistingPrds([]);
} }
}, [projectName]); }, [projectId]);
useEffect(() => { useEffect(() => {
void refreshExistingPrds(); void refreshExistingPrds();

View File

@@ -4,7 +4,8 @@ import type { ExistingPrdFile, SavePrdInput, SavePrdResult } from '../types';
import { ensurePrdExtension } from '../utils/fileName'; import { ensurePrdExtension } from '../utils/fileName';
type UsePrdSaveArgs = { type UsePrdSaveArgs = {
projectName?: string; // DB primary key of the project (post migration).
projectId?: string;
existingPrds: ExistingPrdFile[]; existingPrds: ExistingPrdFile[];
isExistingFile: boolean; isExistingFile: boolean;
onAfterSave?: () => Promise<void>; onAfterSave?: () => Promise<void>;
@@ -17,7 +18,7 @@ type UsePrdSaveResult = {
}; };
export function usePrdSave({ export function usePrdSave({
projectName, projectId,
existingPrds, existingPrds,
isExistingFile, isExistingFile,
onAfterSave, onAfterSave,
@@ -44,7 +45,7 @@ export function usePrdSave({
return { status: 'failed', message: 'Please provide a filename for the PRD.' }; 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.' }; return { status: 'failed', message: 'No project selected. Please reopen the editor.' };
} }
@@ -59,7 +60,7 @@ export function usePrdSave({
setSaving(true); setSaving(true);
try { try {
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectName)}`, { const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectId)}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
fileName: finalFileName, fileName: finalFileName,
@@ -100,7 +101,7 @@ export function usePrdSave({
setSaving(false); setSaving(false);
} }
}, },
[existingPrds, isExistingFile, onAfterSave, projectName], [existingPrds, isExistingFile, onAfterSave, projectId],
); );
return { return {

View File

@@ -1,7 +1,8 @@
export type PrdFile = { export type PrdFile = {
name?: string; name?: string;
path?: string; path?: string;
projectName?: string; // DB projectId used to resolve the project path when fetching file content.
projectId?: string;
content?: string; content?: string;
isExisting?: boolean; isExisting?: boolean;
}; };

View File

@@ -1,4 +1,5 @@
import type { AgentCategoryContentSectionProps } from '../types'; import type { AgentCategoryContentSectionProps } from '../types';
import type { McpProject } from '../../../../../mcp/types';
import { McpServers } from '../../../../../mcp'; import { McpServers } from '../../../../../mcp';
import AccountContent from './content/AccountContent'; import AccountContent from './content/AccountContent';
@@ -71,9 +72,16 @@ export default function AgentCategoryContentSection({
)} )}
{selectedCategory === 'mcp' && ( {selectedCategory === 'mcp' && (
// SettingsProject.name is populated from the DB projectId by
// normalizeProjectForSettings, so we can map it straight through.
<McpServers <McpServers
selectedProvider={selectedAgent} selectedProvider={selectedAgent}
currentProjects={projects} currentProjects={projects.map<McpProject>((project) => ({
projectId: project.name,
displayName: project.displayName,
fullPath: project.fullPath,
path: project.path,
}))}
/> />
)} )}
</div> </div>

View File

@@ -42,6 +42,9 @@ type ConversationSession = {
}; };
type ConversationProjectResult = { 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; projectName: string;
projectDisplayName: string; projectDisplayName: string;
sessions: ConversationSession[]; sessions: ConversationSession[];
@@ -69,7 +72,8 @@ type UseSidebarControllerArgs = {
onProjectSelect: (project: Project) => void; onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void; onSessionSelect: (session: ProjectSession) => void;
onSessionDelete?: (sessionId: string) => 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; setCurrentProject: (project: Project) => void;
setSidebarVisible: (visible: boolean) => void; setSidebarVisible: (visible: boolean) => void;
sidebarVisible: boolean; sidebarVisible: boolean;
@@ -135,13 +139,15 @@ export function useSidebarController({
}, [projects]); }, [projects]);
useEffect(() => { useEffect(() => {
// Expanded-project tracking is now keyed by the DB `projectId` so state
// survives display-name edits and other mutations.
if (selectedProject) { if (selectedProject) {
setExpandedProjects((prev) => { setExpandedProjects((prev) => {
if (prev.has(selectedProject.name)) { if (prev.has(selectedProject.projectId)) {
return prev; return prev;
} }
const next = new Set(prev); const next = new Set(prev);
next.add(selectedProject.name); next.add(selectedProject.projectId);
return next; return next;
}); });
} }
@@ -152,7 +158,7 @@ export function useSidebarController({
const loadedProjects = new Set<string>(); const loadedProjects = new Set<string>();
projects.forEach((project) => { projects.forEach((project) => {
if (project.sessions && project.sessions.length >= 0) { if (project.sessions && project.sessions.length >= 0) {
loadedProjects.add(project.name); loadedProjects.add(project.projectId);
} }
}); });
setInitialSessionsLoaded(loadedProjects); 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) => { setExpandedProjects((prev) => {
const next = new Set<string>(); const next = new Set<string>();
if (!prev.has(projectName)) { if (!prev.has(projectId)) {
next.add(projectName); next.add(projectId);
} }
return next; return next;
}); });
}, []); }, []);
const handleSessionClick = useCallback( const handleSessionClick = useCallback(
(session: SessionWithProvider, projectName: string) => { (session: SessionWithProvider, projectId: string) => {
onSessionSelect({ ...session, __projectName: projectName }); // 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], [onSessionSelect],
); );
const toggleStarProject = useCallback((projectName: string) => { const toggleStarProject = useCallback((projectId: string) => {
setStarredProjects((prev) => { setStarredProjects((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(projectName)) { if (next.has(projectId)) {
next.delete(projectName); next.delete(projectId);
} else { } else {
next.add(projectName); next.add(projectId);
} }
persistStarredProjects(next); persistStarredProjects(next);
@@ -328,7 +338,7 @@ export function useSidebarController({
}, []); }, []);
const isProjectStarred = useCallback( const isProjectStarred = useCallback(
(projectName: string) => starredProjects.has(projectName), (projectId: string) => starredProjects.has(projectId),
[starredProjects], [starredProjects],
); );
@@ -340,7 +350,8 @@ export function useSidebarController({
const projectsWithSessionMeta = useMemo( const projectsWithSessionMeta = useMemo(
() => () =>
projects.map((project) => { 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) { if (hasMoreOverride === undefined) {
return project; return project;
} }
@@ -364,7 +375,9 @@ export function useSidebarController({
); );
const startEditing = useCallback((project: Project) => { 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); setEditingName(project.displayName);
}, []); }, []);
@@ -374,9 +387,11 @@ export function useSidebarController({
}, []); }, []);
const saveProjectName = useCallback( 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 { try {
const response = await api.renameProject(projectName, editingName); const response = await api.renameProject(projectId, editingName);
if (response.ok) { if (response.ok) {
if (window.refreshProjects) { if (window.refreshProjects) {
await window.refreshProjects(); await window.refreshProjects();
@@ -397,13 +412,15 @@ export function useSidebarController({
); );
const showDeleteSessionConfirmation = useCallback( 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, sessionId: string,
sessionTitle: string, sessionTitle: string,
provider: SessionDeleteConfirmation['provider'] = 'claude', provider: SessionDeleteConfirmation['provider'] = 'claude',
) => { ) => {
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider }); setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider });
}, },
[], [],
); );
@@ -413,7 +430,7 @@ export function useSidebarController({
return; return;
} }
const { projectName, sessionId, provider } = sessionDeleteConfirmation; const { projectId, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null); setSessionDeleteConfirmation(null);
try { try {
@@ -423,7 +440,8 @@ export function useSidebarController({
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
response = await api.deleteGeminiSession(sessionId); response = await api.deleteGeminiSession(sessionId);
} else { } 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) { if (response.ok) {
@@ -461,13 +479,15 @@ export function useSidebarController({
const isEmpty = sessionCount === 0; const isEmpty = sessionCount === 0;
setDeleteConfirmation(null); 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 { try {
const response = await api.deleteProject(project.name, !isEmpty, deleteData); const response = await api.deleteProject(project.projectId, !isEmpty, deleteData);
if (response.ok) { if (response.ok) {
onProjectDelete?.(project.name); onProjectDelete?.(project.projectId);
} else { } else {
const error = (await response.json()) as { error?: string }; const error = (await response.json()) as { error?: string };
alert(error.error || t('messages.deleteProjectFailed')); alert(error.error || t('messages.deleteProjectFailed'));
@@ -478,7 +498,7 @@ export function useSidebarController({
} finally { } finally {
setDeletingProjects((prev) => { setDeletingProjects((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(project.name); next.delete(project.projectId);
return next; return next;
}); });
} }
@@ -486,19 +506,21 @@ export function useSidebarController({
const loadMoreSessions = useCallback( const loadMoreSessions = useCallback(
async (project: Project) => { 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 = const canLoadMore =
hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true; hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true;
if (!canLoadMore || loadingSessions[project.name]) { if (!canLoadMore || loadingSessions[project.projectId]) {
return; return;
} }
setLoadingSessions((prev) => ({ ...prev, [project.name]: true })); setLoadingSessions((prev) => ({ ...prev, [project.projectId]: true }));
try { try {
const currentSessionCount = const currentSessionCount =
(project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0); (project.sessions?.length || 0) + (additionalSessions[project.projectId]?.length || 0);
const response = await api.sessions(project.name, 5, currentSessionCount); const response = await api.sessions(project.projectId, 5, currentSessionCount);
if (!response.ok) { if (!response.ok) {
return; return;
@@ -511,17 +533,17 @@ export function useSidebarController({
setAdditionalSessions((prev) => ({ setAdditionalSessions((prev) => ({
...prev, ...prev,
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])], [project.projectId]: [...(prev[project.projectId] || []), ...(result.sessions || [])],
})); }));
if (result.hasMore === false) { if (result.hasMore === false) {
// Keep hasMore state in local hook state instead of mutating the project prop object. // 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) { } catch (error) {
console.error('Error loading more sessions:', error); console.error('Error loading more sessions:', error);
} finally { } finally {
setLoadingSessions((prev) => ({ ...prev, [project.name]: false })); setLoadingSessions((prev) => ({ ...prev, [project.projectId]: false }));
} }
}, },
[additionalSessions, loadingSessions, projectHasMoreOverrides], [additionalSessions, loadingSessions, projectHasMoreOverrides],
@@ -545,7 +567,9 @@ export function useSidebarController({
}, [onRefresh]); }, [onRefresh]);
const updateSessionSummary = useCallback( 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(); const trimmed = summary.trim();
if (!trimmed) { if (!trimmed) {
setEditingSession(null); setEditingSession(null);

View File

@@ -14,8 +14,10 @@ export type DeleteProjectConfirmation = {
sessionCount: number; sessionCount: number;
}; };
// Delete confirmation payload; `projectId` is the DB primary key used by the
// DELETE /api/projects/:projectId/sessions/:sessionId endpoint.
export type SessionDeleteConfirmation = { export type SessionDeleteConfirmation = {
projectName: string; projectId: string;
sessionId: string; sessionId: string;
sessionTitle: string; sessionTitle: string;
provider: LLMProvider; provider: LLMProvider;
@@ -29,7 +31,9 @@ export type SidebarProps = {
onSessionSelect: (session: ProjectSession) => void; onSessionSelect: (session: ProjectSession) => void;
onNewSession: (project: Project) => void; onNewSession: (project: Project) => void;
onSessionDelete?: (sessionId: string) => 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; isLoading: boolean;
loadingProgress: LoadingProgress | null; loadingProgress: LoadingProgress | null;
onRefresh: () => Promise<void> | void; onRefresh: () => Promise<void> | void;
@@ -55,4 +59,11 @@ export type MCPServerStatus = {
isConfigured?: boolean; isConfigured?: boolean;
} | null; } | null;
export type SettingsProject = Pick<Project, 'name' | 'displayName' | 'fullPath' | 'path'>; // 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;
};

View File

@@ -102,9 +102,11 @@ export const getAllSessions = (
project: Project, project: Project,
additionalSessions: AdditionalSessionsByProject, additionalSessions: AdditionalSessionsByProject,
): SessionWithProvider[] => { ): SessionWithProvider[] => {
// `additionalSessions` is indexed by DB `projectId` now (the sidebar keys
// every per-project map by the same identifier).
const claudeSessions = [ const claudeSessions = [
...(project.sessions || []), ...(project.sessions || []),
...(additionalSessions[project.name] || []), ...(additionalSessions[project.projectId] || []),
].map((session) => ({ ...session, __provider: 'claude' as const })); ].map((session) => ({ ...session, __provider: 'claude' as const }));
const cursorSessions = (project.cursorSessions || []).map((session) => ({ const cursorSessions = (project.cursorSessions || []).map((session) => ({
@@ -151,8 +153,9 @@ export const sortProjects = (
const byName = [...projects]; const byName = [...projects];
byName.sort((projectA, projectB) => { byName.sort((projectA, projectB) => {
const aStarred = starredProjects.has(projectA.name); // Starred projects are tracked by `projectId` in localStorage.
const bStarred = starredProjects.has(projectB.name); const aStarred = starredProjects.has(projectA.projectId);
const bStarred = starredProjects.has(projectB.projectId);
if (aStarred && !bStarred) { if (aStarred && !bStarred) {
return -1; 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; return byName;
@@ -182,9 +185,11 @@ export const filterProjects = (projects: Project[], searchFilter: string): Proje
} }
return projects.filter((project) => { return projects.filter((project) => {
const displayName = (project.displayName || project.name).toLowerCase(); const displayName = (project.displayName || project.projectId).toLowerCase();
const projectName = project.name.toLowerCase(); // `project.path`/`fullPath` is the most useful search target now that the
return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch); // 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 ? 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 { return {
name: project.name, name: project.projectId,
displayName: displayName:
typeof project.displayName === 'string' && project.displayName.trim().length > 0 typeof project.displayName === 'string' && project.displayName.trim().length > 0
? project.displayName ? project.displayName
: project.name, : project.projectId,
fullPath: fallbackPath, fullPath: fallbackPath,
path: path:
typeof project.path === 'string' && project.path.length > 0 typeof project.path === 'string' && project.path.length > 0

View File

@@ -234,14 +234,18 @@ function Sidebar({
conversationResults={conversationResults} conversationResults={conversationResults}
isSearching={isSearching} isSearching={isSearching}
searchProgress={searchProgress} 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 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 searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
const sessionObj = { const sessionObj = {
id: sessionId, id: sessionId,
__provider: resolvedProvider, __provider: resolvedProvider,
__projectName: projectName, __projectId: projectId ?? undefined,
...searchTarget, ...searchTarget,
}; };
if (project) { if (project) {
@@ -249,12 +253,12 @@ function Sidebar({
const sessions = getProjectSessions(project); const sessions = getProjectSessions(project);
const existing = sessions.find(s => s.id === sessionId); const existing = sessions.find(s => s.id === sessionId);
if (existing) { if (existing) {
handleSessionClick({ ...existing, ...searchTarget }, projectName); handleSessionClick({ ...existing, ...searchTarget }, project.projectId);
} else { } else {
handleSessionClick(sessionObj, projectName); handleSessionClick(sessionObj, project.projectId);
} }
} else { } else {
handleSessionClick(sessionObj, projectName); handleSessionClick(sessionObj, projectId ?? '');
} }
}} }}
onRefresh={() => { onRefresh={() => {

View File

@@ -48,7 +48,9 @@ type SidebarContentProps = {
conversationResults: ConversationSearchResults | null; conversationResults: ConversationSearchResults | null;
isSearching: boolean; isSearching: boolean;
searchProgress: SearchProgress | null; 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; onRefresh: () => void;
isRefreshing: boolean; isRefreshing: boolean;
onCreateProject: () => void; onCreateProject: () => void;
@@ -170,10 +172,12 @@ export default function SidebarContent({
</div> </div>
{projectResult.sessions.map((session) => ( {projectResult.sessions.map((session) => (
<button <button
key={`${projectResult.projectName}-${session.sessionId}`} key={`${projectResult.projectId ?? projectResult.projectName}-${session.sessionId}`}
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50" className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
onClick={() => onConversationResultClick( onClick={() => onConversationResultClick(
projectResult.projectName, // Pass the DB projectId (preferred) so the parent can
// cross-reference with the loaded projects list.
projectResult.projectId,
session.sessionId, session.sessionId,
session.provider || session.matches[0]?.provider || 'claude', session.provider || session.matches[0]?.provider || 'claude',
session.matches[0]?.timestamp, session.matches[0]?.timestamp,

View File

@@ -114,7 +114,7 @@ export default function SidebarModals({
<p className="mb-1 text-sm text-muted-foreground"> <p className="mb-1 text-sm text-muted-foreground">
{t('deleteConfirmation.confirmDelete')}{' '} {t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{deleteConfirmation.project.displayName || deleteConfirmation.project.name} {deleteConfirmation.project.displayName || deleteConfirmation.project.projectId}
</span> </span>
? ?
</p> </p>

View File

@@ -93,22 +93,24 @@ export default function SidebarProjectItem({
onSaveEditingSession, onSaveEditingSession,
t, t,
}: SidebarProjectItemProps) { }: SidebarProjectItemProps) {
const isSelected = selectedProject?.name === project.name; // Project identity is tracked by the DB-assigned `projectId` everywhere
const isEditing = editingProject === project.name; // after the projectName → projectId migration.
const isSelected = selectedProject?.projectId === project.projectId;
const isEditing = editingProject === project.projectId;
const hasMoreSessions = project.sessionMeta?.hasMore === true; const hasMoreSessions = project.sessionMeta?.hasMore === true;
const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions); const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions);
const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`; const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;
const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus); const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
const toggleProject = () => onToggleProject(project.name); const toggleProject = () => onToggleProject(project.projectId);
const toggleStarProject = () => onToggleStarProject(project.name); const toggleStarProject = () => onToggleStarProject(project.projectId);
const saveProjectName = () => { const saveProjectName = () => {
onSaveProjectName(project.name); onSaveProjectName(project.projectId);
}; };
const selectAndToggleProject = () => { const selectAndToggleProject = () => {
if (selectedProject?.name !== project.name) { if (selectedProject?.projectId !== project.projectId) {
onProjectSelect(project); onProjectSelect(project);
} }

View File

@@ -117,19 +117,21 @@ export default function SidebarProjectList({
{!showProjects {!showProjects
? state ? state
: filteredProjects.map((project) => ( : filteredProjects.map((project) => (
// React key + per-project state lookups all use the DB `projectId`
// so they remain stable across renames and session changes.
<SidebarProjectItem <SidebarProjectItem
key={project.name} key={project.projectId}
project={project} project={project}
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}
isExpanded={expandedProjects.has(project.name)} isExpanded={expandedProjects.has(project.projectId)}
isDeleting={deletingProjects.has(project.name)} isDeleting={deletingProjects.has(project.projectId)}
isStarred={isProjectStarred(project.name)} isStarred={isProjectStarred(project.projectId)}
editingProject={editingProject} editingProject={editingProject}
editingName={editingName} editingName={editingName}
sessions={getProjectSessions(project)} sessions={getProjectSessions(project)}
initialSessionsLoaded={initialSessionsLoaded.has(project.name)} initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
isLoadingSessions={Boolean(loadingSessions[project.name])} isLoadingSessions={Boolean(loadingSessions[project.projectId])}
currentTime={currentTime} currentTime={currentTime}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}

View File

@@ -49,17 +49,19 @@ export default function SidebarSessionItem({
const sessionView = createSessionViewModel(session, currentTime, t); const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id; const isSelected = selectedSession?.id === session.id;
// Sessions are owned by a project identified by `projectId` (DB primary key)
// after the projectName → projectId migration.
const selectMobileSession = () => { const selectMobileSession = () => {
onProjectSelect(project); onProjectSelect(project);
onSessionSelect(session, project.name); onSessionSelect(session, project.projectId);
}; };
const saveEditedSession = () => { const saveEditedSession = () => {
onSaveEditingSession(project.name, session.id, editingSessionName, session.__provider); onSaveEditingSession(project.projectId, session.id, editingSessionName, session.__provider);
}; };
const requestDeleteSession = () => { const requestDeleteSession = () => {
onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider); onDeleteSession(project.projectId, session.id, sessionView.sessionName, session.__provider);
}; };
return ( return (
@@ -131,7 +133,7 @@ export default function SidebarSessionItem({
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200', 'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
isSelected && 'bg-accent text-accent-foreground', isSelected && 'bg-accent text-accent-foreground',
)} )}
onClick={() => onSessionSelect(session, project.name)} onClick={() => onSessionSelect(session, project.projectId)}
> >
<div className="flex w-full min-w-0 items-start gap-2"> <div className="flex w-full min-w-0 items-start gap-2">
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" /> <SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />

View File

@@ -74,13 +74,15 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
const [isLoadingMCP, setIsLoadingMCP] = useState(false); const [isLoadingMCP, setIsLoadingMCP] = useState(false);
const [error, setError] = useState<TaskMasterContextError | null>(null); const [error, setError] = useState<TaskMasterContextError | null>(null);
const currentProjectNameRef = useRef<string | null>(null); // Track the active project via DB `projectId`; everything downstream uses
// the same identifier post-migration.
const currentProjectIdRef = useRef<string | null>(null);
const projectTaskMasterRef = useRef<TaskMasterProjectInfo | null>(null); const projectTaskMasterRef = useRef<TaskMasterProjectInfo | null>(null);
const taskMasterRequestSeqRef = useRef(0); const taskMasterRequestSeqRef = useRef(0);
useEffect(() => { useEffect(() => {
currentProjectNameRef.current = currentProject?.name ?? null; currentProjectIdRef.current = currentProject?.projectId ?? null;
}, [currentProject?.name]); }, [currentProject?.projectId]);
useEffect(() => { useEffect(() => {
projectTaskMasterRef.current = projectTaskMaster; projectTaskMasterRef.current = projectTaskMaster;
@@ -95,12 +97,14 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
setError(createTaskMasterError(context, caughtError)); setError(createTaskMasterError(context, caughtError));
}, []); }, []);
const applyTaskMasterInfo = useCallback((projectName: string, taskMasterInfo: TaskMasterProjectInfo | null) => { // Looks up projects by DB `projectId`; the legacy folder-derived `name`
// field has been removed from Project post-migration.
const applyTaskMasterInfo = useCallback((projectId: string, taskMasterInfo: TaskMasterProjectInfo | null) => {
setProjectTaskMaster(taskMasterInfo); setProjectTaskMaster(taskMasterInfo);
setProjects((previousProjects) => setProjects((previousProjects) =>
previousProjects.map((project) => { previousProjects.map((project) => {
if (project.name !== projectName) { if (project.projectId !== projectId) {
return project; return project;
} }
@@ -112,7 +116,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
); );
setCurrentProjectState((previousProject) => { setCurrentProjectState((previousProject) => {
if (!previousProject || previousProject.name !== projectName) { if (!previousProject || previousProject.projectId !== projectId) {
return previousProject; return previousProject;
} }
@@ -124,15 +128,15 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, []); }, []);
const refreshCurrentProjectTaskMaster = useCallback( const refreshCurrentProjectTaskMaster = useCallback(
async (projectName: string) => { async (projectId: string) => {
if (!projectName || !user || !token) { if (!projectId || !user || !token) {
return; return;
} }
const requestSequence = ++taskMasterRequestSeqRef.current; const requestSequence = ++taskMasterRequestSeqRef.current;
try { try {
const response = await api.projectTaskmaster(projectName); const response = await api.projectTaskmaster(projectId);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch TaskMaster details: ${response.status}`); throw new Error(`Failed to fetch TaskMaster details: ${response.status}`);
} }
@@ -142,16 +146,16 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
if ( if (
requestSequence !== taskMasterRequestSeqRef.current requestSequence !== taskMasterRequestSeqRef.current
|| currentProjectNameRef.current !== projectName || currentProjectIdRef.current !== projectId
) { ) {
return; return;
} }
applyTaskMasterInfo(projectName, resolvedTaskMasterInfo); applyTaskMasterInfo(projectId, resolvedTaskMasterInfo);
} catch (caughtError) { } catch (caughtError) {
if ( if (
requestSequence !== taskMasterRequestSeqRef.current requestSequence !== taskMasterRequestSeqRef.current
|| currentProjectNameRef.current !== projectName || currentProjectIdRef.current !== projectId
) { ) {
return; return;
} }
@@ -172,12 +176,13 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
setTasks([]); setTasks([]);
setNextTask(null); setNextTask(null);
if (!normalizedProject?.name) { // `projectId` is the DB primary key used for every TaskMaster API call.
if (!normalizedProject?.projectId) {
taskMasterRequestSeqRef.current += 1; taskMasterRequestSeqRef.current += 1;
return; return;
} }
void refreshCurrentProjectTaskMaster(normalizedProject.name); void refreshCurrentProjectTaskMaster(normalizedProject.projectId);
}, },
[refreshCurrentProjectTaskMaster], [refreshCurrentProjectTaskMaster],
); );
@@ -206,14 +211,15 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
const enrichedProjects = loadedProjects.map((project) => enrichProject(project)); const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
setProjects((previousProjects) => { setProjects((previousProjects) => {
const taskMasterByProjectName = new Map( // Cache is keyed by `projectId` (DB primary key) post-migration.
const taskMasterByProjectId = new Map(
previousProjects previousProjects
.filter((project) => Boolean(project.taskmaster)) .filter((project) => Boolean(project.taskmaster))
.map((project) => [project.name, project.taskmaster]), .map((project) => [project.projectId, project.taskmaster]),
); );
return enrichedProjects.map((project) => { return enrichedProjects.map((project) => {
const cachedTaskMasterInfo = taskMasterByProjectName.get(project.name); const cachedTaskMasterInfo = taskMasterByProjectId.get(project.projectId);
if (!cachedTaskMasterInfo) { if (!cachedTaskMasterInfo) {
return project; return project;
} }
@@ -225,12 +231,12 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}); });
}); });
const currentProjectName = currentProjectNameRef.current; const currentProjectId = currentProjectIdRef.current;
if (!currentProjectName) { if (!currentProjectId) {
return; return;
} }
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null; const matchingProject = enrichedProjects.find((project) => project.projectId === currentProjectId) ?? null;
if (!matchingProject) { if (!matchingProject) {
taskMasterRequestSeqRef.current += 1; taskMasterRequestSeqRef.current += 1;
@@ -252,7 +258,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
); );
setProjectTaskMaster(cachedTaskMasterInfo); setProjectTaskMaster(cachedTaskMasterInfo);
void refreshCurrentProjectTaskMaster(currentProjectName); void refreshCurrentProjectTaskMaster(currentProjectId);
} catch (caughtError) { } catch (caughtError) {
handleError('load projects', caughtError); handleError('load projects', caughtError);
} finally { } finally {
@@ -261,9 +267,10 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, [clearError, handleError, refreshCurrentProjectTaskMaster, token, user]); }, [clearError, handleError, refreshCurrentProjectTaskMaster, token, user]);
const refreshTasks = useCallback(async () => { const refreshTasks = useCallback(async () => {
const projectName = currentProject?.name; // TaskMaster tasks endpoint now lives under /api/taskmaster/tasks/:projectId.
const projectId = currentProject?.projectId;
if (!projectName || !user || !token) { if (!projectId || !user || !token) {
setTasks([]); setTasks([]);
setNextTask(null); setNextTask(null);
return; return;
@@ -273,7 +280,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
setIsLoadingTasks(true); setIsLoadingTasks(true);
clearError(); clearError();
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`); const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectId)}`);
if (!response.ok) { if (!response.ok) {
const errorPayload = (await response.json()) as { message?: string }; const errorPayload = (await response.json()) as { message?: string };
throw new Error(errorPayload.message ?? 'Failed to load tasks'); throw new Error(errorPayload.message ?? 'Failed to load tasks');
@@ -291,7 +298,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
} finally { } finally {
setIsLoadingTasks(false); setIsLoadingTasks(false);
} }
}, [clearError, currentProject?.name, handleError, token, user]); }, [clearError, currentProject?.projectId, handleError, token, user]);
const refreshMCPStatus = useCallback(async () => { const refreshMCPStatus = useCallback(async () => {
if (!user || !token) { if (!user || !token) {
@@ -326,10 +333,10 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]); }, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
useEffect(() => { useEffect(() => {
if (currentProject?.name && user && token) { if (currentProject?.projectId && user && token) {
void refreshTasks(); void refreshTasks();
} }
}, [currentProject?.name, refreshTasks, token, user]); }, [currentProject?.projectId, refreshTasks, token, user]);
useEffect(() => { useEffect(() => {
const message = latestMessage as TaskMasterWebSocketMessage | null; const message = latestMessage as TaskMasterWebSocketMessage | null;
@@ -337,15 +344,16 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
return; return;
} }
if (message.type === 'taskmaster-project-updated' && message.projectName) { // Broadcasts now identify projects by `projectId` (see taskmaster-websocket.js).
if (message.projectName === currentProjectNameRef.current) { if (message.type === 'taskmaster-project-updated' && message.projectId) {
void refreshCurrentProjectTaskMaster(message.projectName); if (message.projectId === currentProjectIdRef.current) {
void refreshCurrentProjectTaskMaster(message.projectId);
} }
void refreshProjects(); void refreshProjects();
return; return;
} }
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) { if (message.type === 'taskmaster-tasks-updated' && message.projectId === currentProject?.projectId) {
void refreshTasks(); void refreshTasks();
return; return;
} }
@@ -353,7 +361,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
if (message.type === 'taskmaster-mcp-status-changed') { if (message.type === 'taskmaster-mcp-status-changed') {
void refreshMCPStatus(); void refreshMCPStatus();
} }
}, [currentProject?.name, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]); }, [currentProject?.projectId, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]);
const contextValue = useMemo<TaskMasterContextValue>( const contextValue = useMemo<TaskMasterContextValue>(
() => ({ () => ({

View File

@@ -3,7 +3,8 @@ import { api } from '../../../utils/api';
import type { PrdFile } from '../types'; import type { PrdFile } from '../types';
type UseProjectPrdFilesOptions = { type UseProjectPrdFilesOptions = {
projectName?: string; // DB primary key of the project (post migration).
projectId?: string;
}; };
type PrdResponse = { type PrdResponse = {
@@ -23,19 +24,19 @@ function normalizePrdResponse(responseData: PrdResponse): PrdFile[] {
return []; return [];
} }
export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) { export function useProjectPrdFiles({ projectId }: UseProjectPrdFilesOptions) {
const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]); const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);
const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false); const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);
const refreshPrdFiles = useCallback(async () => { const refreshPrdFiles = useCallback(async () => {
if (!projectName) { if (!projectId) {
setPrdFiles([]); setPrdFiles([]);
return; return;
} }
try { try {
setIsLoadingPrdFiles(true); setIsLoadingPrdFiles(true);
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`); const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectId)}`);
if (!response.ok) { if (!response.ok) {
setPrdFiles([]); setPrdFiles([]);
@@ -50,7 +51,7 @@ export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
} finally { } finally {
setIsLoadingPrdFiles(false); setIsLoadingPrdFiles(false);
} }
}, [projectName]); }, [projectId]);
useEffect(() => { useEffect(() => {
void refreshPrdFiles(); void refreshPrdFiles();

View File

@@ -90,7 +90,8 @@ export type TaskMasterMcpStatus = {
export type TaskMasterWebSocketMessage = { export type TaskMasterWebSocketMessage = {
type?: string; type?: string;
projectName?: string; // Post-migration TaskMaster broadcasts identify projects by `projectId`.
projectId?: string;
[key: string]: unknown; [key: string]: unknown;
}; };

View File

@@ -72,13 +72,14 @@ export default function TaskBoard({
); );
const loadPrdAndOpenEditor = async (prd: PrdFile) => { const loadPrdAndOpenEditor = async (prd: PrdFile) => {
if (!currentProject?.name) { // Projects are addressed by DB projectId; see the projectName → projectId migration.
if (!currentProject?.projectId) {
return; return;
} }
try { try {
const response = await api.get( const response = await api.get(
`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`, `/taskmaster/prd/${encodeURIComponent(currentProject.projectId)}/${encodeURIComponent(prd.name)}`,
); );
if (!response.ok) { if (!response.ok) {

View File

@@ -24,7 +24,7 @@ export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
const [prdNotification, setPrdNotification] = useState<string | null>(null); const [prdNotification, setPrdNotification] = useState<string | null>(null);
const notificationTimeoutRef = useRef<number | null>(null); const notificationTimeoutRef = useRef<number | null>(null);
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name }); const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectId: currentProject?.projectId });
const showPrdNotification = useCallback((message: string) => { const showPrdNotification = useCallback((message: string) => {
if (notificationTimeoutRef.current) { if (notificationTimeoutRef.current) {

View File

@@ -5,12 +5,16 @@
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true'; export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
/** /**
* For empty shell instances where no project is provided, * For empty shell instances where no project is provided,
* we use a default project object to ensure the shell can still function. * we use a default project object to ensure the shell can still function.
* This prevents errors related to missing project data. * This prevents errors related to missing project data.
*
* `projectId` is set to a well-known sentinel ('default') because the empty
* shell doesn't correspond to any real project row in the database; any API
* call that routes through this placeholder must tolerate a missing match.
*/ */
export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = { export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = {
name: 'default', projectId: 'default',
displayName: 'default', displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '', fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '', path: IS_PLATFORM ? '/workspace' : '',

View File

@@ -41,7 +41,7 @@ const projectsHaveChanges = (
} }
const baseChanged = const baseChanged =
nextProject.name !== prevProject.name || nextProject.projectId !== prevProject.projectId ||
nextProject.displayName !== prevProject.displayName || nextProject.displayName !== prevProject.displayName ||
nextProject.fullPath !== prevProject.fullPath || nextProject.fullPath !== prevProject.fullPath ||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
@@ -69,14 +69,16 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
return nextProjects; return nextProjects;
} }
// Keyed by `projectId` (the DB primary key) so caches stay correct across
// renames and other mutations that might have changed the display name.
const previousTaskMasterByProject = new Map( const previousTaskMasterByProject = new Map(
previousProjects previousProjects
.filter((project) => Boolean(project.taskmaster)) .filter((project) => Boolean(project.taskmaster))
.map((project) => [project.name, project.taskmaster]), .map((project) => [project.projectId, project.taskmaster]),
); );
return nextProjects.map((project) => { return nextProjects.map((project) => {
const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.name); const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.projectId);
if (!cachedTaskMasterInfo) { if (!cachedTaskMasterInfo) {
return project; return project;
} }
@@ -107,8 +109,8 @@ const isUpdateAdditive = (
return true; return true;
} }
const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name); const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name); const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!currentSelectedProject || !updatedSelectedProject) { if (!currentSelectedProject || !updatedSelectedProject) {
return false; return false;
@@ -214,13 +216,15 @@ export function useProjectsState({
await fetchProjects({ showLoadingState: false }); await fetchProjects({ showLoadingState: false });
}, [fetchProjects]); }, [fetchProjects]);
const hydrateProjectTaskMaster = useCallback(async (projectName: string) => { // Hydrates TaskMaster details for the given `projectId`. The project
if (!projectName) { // identifier comes directly from the DB-driven /api/projects response.
const hydrateProjectTaskMaster = useCallback(async (projectId: string) => {
if (!projectId) {
return; return;
} }
try { try {
const response = await api.projectTaskmaster(projectName); const response = await api.projectTaskmaster(projectId);
if (!response.ok) { if (!response.ok) {
return; return;
} }
@@ -233,14 +237,14 @@ export function useProjectsState({
setProjects((previousProjects) => setProjects((previousProjects) =>
previousProjects.map((project) => previousProjects.map((project) =>
project.name === projectName project.projectId === projectId
? { ...project, taskmaster: taskMasterInfo } ? { ...project, taskmaster: taskMasterInfo }
: project, : project,
), ),
); );
setSelectedProject((previousProject) => { setSelectedProject((previousProject) => {
if (!previousProject || previousProject.name !== projectName) { if (!previousProject || previousProject.projectId !== projectId) {
return previousProject; return previousProject;
} }
@@ -250,7 +254,7 @@ export function useProjectsState({
}; };
}); });
} catch (error) { } catch (error) {
console.error(`Error fetching TaskMaster info for project ${projectName}:`, error); console.error(`Error fetching TaskMaster info for project ${projectId}:`, error);
} }
}, []); }, []);
@@ -264,12 +268,12 @@ export function useProjectsState({
}, [fetchProjects]); }, [fetchProjects]);
useEffect(() => { useEffect(() => {
if (!selectedProject?.name) { if (!selectedProject?.projectId) {
return; return;
} }
void hydrateProjectTaskMaster(selectedProject.name); void hydrateProjectTaskMaster(selectedProject.projectId);
}, [hydrateProjectTaskMaster, selectedProject?.name]); }, [hydrateProjectTaskMaster, selectedProject?.projectId]);
// Auto-select the project when there is only one, so the user lands on the new session page // Auto-select the project when there is only one, so the user lands on the new session page
useEffect(() => { useEffect(() => {
@@ -345,7 +349,7 @@ export function useProjectsState({
} }
const updatedSelectedProject = updatedProjects.find( const updatedSelectedProject = updatedProjects.find(
(project) => project.name === selectedProject.name, (project) => project.projectId === selectedProject.projectId,
); );
if (!updatedSelectedProject) { if (!updatedSelectedProject) {
@@ -383,10 +387,11 @@ export function useProjectsState({
return; return;
} }
// Project membership is resolved through `projectId` after the migration.
for (const project of projects) { for (const project of projects) {
const claudeSession = project.sessions?.find((session) => session.id === sessionId); const claudeSession = project.sessions?.find((session) => session.id === sessionId);
if (claudeSession) { if (claudeSession) {
const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude'; selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
@@ -401,7 +406,7 @@ export function useProjectsState({
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId); const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
if (cursorSession) { if (cursorSession) {
const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor'; selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
@@ -416,7 +421,7 @@ export function useProjectsState({
const codexSession = project.codexSessions?.find((session) => session.id === sessionId); const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
if (codexSession) { if (codexSession) {
const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex'; selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
@@ -431,7 +436,7 @@ export function useProjectsState({
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId); const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
if (geminiSession) { if (geminiSession) {
const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini'; selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
@@ -444,7 +449,7 @@ export function useProjectsState({
return; return;
} }
} }
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]); }, [sessionId, projects, selectedProject?.projectId, selectedSession?.id, selectedSession?.__provider]);
const handleProjectSelect = useCallback( const handleProjectSelect = useCallback(
(project: Project) => { (project: Project) => {
@@ -473,17 +478,21 @@ export function useProjectsState({
} }
if (isMobile) { if (isMobile) {
const sessionProjectName = session.__projectName; // Sessions are tagged with the owning project's DB `projectId` when
const currentProjectName = selectedProject?.name; // picked from the sidebar (see useSidebarController); compare against
// the current selection's `projectId` so we know whether to collapse
// the sidebar after navigation.
const sessionProjectId = session.__projectId;
const currentProjectId = selectedProject?.projectId;
if (sessionProjectName !== currentProjectName) { if (sessionProjectId !== currentProjectId) {
setSidebarOpen(false); setSidebarOpen(false);
} }
} }
navigate(`/session/${session.id}`); navigate(`/session/${session.id}`);
}, },
[activeTab, isMobile, navigate, selectedProject?.name], [activeTab, isMobile, navigate, selectedProject?.projectId],
); );
const handleNewSession = useCallback( const handleNewSession = useCallback(
@@ -535,7 +544,7 @@ export function useProjectsState({
return; return;
} }
const refreshedProject = mergedProjects.find((project) => project.name === selectedProject.name); const refreshedProject = mergedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!refreshedProject) { if (!refreshedProject) {
return; return;
} }
@@ -568,17 +577,19 @@ export function useProjectsState({
} }
}, [projects, selectedProject, selectedSession]); }, [projects, selectedProject, selectedSession]);
// `projectId` is the DB identifier passed from the sidebar's delete flow
// after the migration away from folder-derived project names.
const handleProjectDelete = useCallback( const handleProjectDelete = useCallback(
(projectName: string) => { (projectId: string) => {
if (selectedProject?.name === projectName) { if (selectedProject?.projectId === projectId) {
setSelectedProject(null); setSelectedProject(null);
setSelectedSession(null); setSelectedSession(null);
navigate('/'); navigate('/');
} }
setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName)); setProjects((prevProjects) => prevProjects.filter((project) => project.projectId !== projectId));
}, },
[navigate, selectedProject?.name], [navigate, selectedProject?.projectId],
); );
const sidebarSharedProps = useMemo( const sidebarSharedProps = useMemo(

View File

@@ -165,12 +165,16 @@ export function useSessionStore() {
/** /**
* Fetch messages from the unified endpoint and populate serverMessages. * Fetch messages from the unified endpoint and populate serverMessages.
*
* `projectId` is the DB-assigned identifier used by the backend to resolve
* the project's on-disk directory; it replaces the legacy `projectName`
* Claude folder encoding that callers used to pass.
*/ */
const fetchFromServer = useCallback(async ( const fetchFromServer = useCallback(async (
sessionId: string, sessionId: string,
opts: { opts: {
provider?: LLMProvider; provider?: LLMProvider;
projectName?: string; projectId?: string;
projectPath?: string; projectPath?: string;
limit?: number | null; limit?: number | null;
offset?: number; offset?: number;
@@ -183,7 +187,7 @@ export function useSessionStore() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts.provider) params.append('provider', opts.provider); if (opts.provider) params.append('provider', opts.provider);
if (opts.projectName) params.append('projectName', opts.projectName); if (opts.projectId) params.append('projectId', opts.projectId);
if (opts.projectPath) params.append('projectPath', opts.projectPath); if (opts.projectPath) params.append('projectPath', opts.projectPath);
if (opts.limit !== null && opts.limit !== undefined) { if (opts.limit !== null && opts.limit !== undefined) {
params.append('limit', String(opts.limit)); params.append('limit', String(opts.limit));
@@ -224,12 +228,15 @@ export function useSessionStore() {
/** /**
* Load older (paginated) messages and prepend to serverMessages. * Load older (paginated) messages and prepend to serverMessages.
*
* Accepts `projectId` (the DB primary key) so the unified messages endpoint
* can resolve the project path through the database.
*/ */
const fetchMore = useCallback(async ( const fetchMore = useCallback(async (
sessionId: string, sessionId: string,
opts: { opts: {
provider?: LLMProvider; provider?: LLMProvider;
projectName?: string; projectId?: string;
projectPath?: string; projectPath?: string;
limit?: number; limit?: number;
} = {}, } = {},
@@ -239,7 +246,7 @@ export function useSessionStore() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts.provider) params.append('provider', opts.provider); if (opts.provider) params.append('provider', opts.provider);
if (opts.projectName) params.append('projectName', opts.projectName); if (opts.projectId) params.append('projectId', opts.projectId);
if (opts.projectPath) params.append('projectPath', opts.projectPath); if (opts.projectPath) params.append('projectPath', opts.projectPath);
const limit = opts.limit ?? 20; const limit = opts.limit ?? 20;
params.append('limit', String(limit)); params.append('limit', String(limit));
@@ -299,12 +306,15 @@ export function useSessionStore() {
/** /**
* Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated). * Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated).
*
* Uses the DB-assigned `projectId`; the legacy folder-derived projectName
* is no longer accepted here.
*/ */
const refreshFromServer = useCallback(async ( const refreshFromServer = useCallback(async (
sessionId: string, sessionId: string,
opts: { opts: {
provider?: LLMProvider; provider?: LLMProvider;
projectName?: string; projectId?: string;
projectPath?: string; projectPath?: string;
} = {}, } = {},
) => { ) => {
@@ -312,7 +322,7 @@ export function useSessionStore() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts.provider) params.append('provider', opts.provider); if (opts.provider) params.append('provider', opts.provider);
if (opts.projectName) params.append('projectName', opts.projectName); if (opts.projectId) params.append('projectId', opts.projectId);
if (opts.projectPath) params.append('projectPath', opts.projectPath); if (opts.projectPath) params.append('projectPath', opts.projectPath);
const qs = params.toString(); const qs = params.toString();

View File

@@ -13,7 +13,9 @@ export interface ProjectSession {
lastActivity?: string; lastActivity?: string;
messageCount?: number; messageCount?: number;
__provider?: LLMProvider; __provider?: LLMProvider;
__projectName?: string; // Tags the session with the owning project's DB `projectId` so UI handlers
// (session switching, sidebar focus, etc.) can match against selectedProject.
__projectId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -30,8 +32,12 @@ export interface ProjectTaskmasterInfo {
[key: string]: unknown; [key: string]: unknown;
} }
// After the projectName → projectId migration the backend no longer returns a
// folder-derived `name` string. Projects are now addressed everywhere by the
// DB-assigned `projectId` (primary key in the `projects` table), and the UI
// uses the same identifier for routing, state keys and API calls.
export interface Project { export interface Project {
name: string; projectId: string;
displayName: string; displayName: string;
fullPath: string; fullPath: string;
path?: string; path?: string;

View File

@@ -51,16 +51,20 @@ export const api = {
// Protected endpoints // Protected endpoints
// config endpoint removed - no longer needed (frontend uses window.location) // config endpoint removed - no longer needed (frontend uses window.location)
// After the projectName → projectId migration the path/query identifier is
// the DB-assigned `projectId`; parameter names reflect that for clarity.
projects: () => authenticatedFetch('/api/projects'), projects: () => authenticatedFetch('/api/projects'),
projectTaskmaster: (projectName) => projectTaskmaster: (projectId) =>
authenticatedFetch(`/api/projects/${encodeURIComponent(projectName)}/taskmaster`), authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/taskmaster`),
sessions: (projectName, limit = 5, offset = 0) => sessions: (projectId, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`), authenticatedFetch(`/api/projects/${projectId}/sessions?limit=${limit}&offset=${offset}`),
// Unified endpoint — all providers through one URL // Unified endpoint — all providers through one URL. The legacy `projectName`
unifiedSessionMessages: (sessionId, provider = 'claude', { projectName = '', projectPath = '', limit = null, offset = 0 } = {}) => { // query parameter is preserved on the wire (routes/messages.js still reads
// it) but it now carries a projectId value supplied by the caller.
unifiedSessionMessages: (sessionId, provider = 'claude', { projectId = '', projectPath = '', limit = null, offset = 0 } = {}) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('provider', provider); params.append('provider', provider);
if (projectName) params.append('projectName', projectName); if (projectId) params.append('projectId', projectId);
if (projectPath) params.append('projectPath', projectPath); if (projectPath) params.append('projectPath', projectPath);
if (limit !== null) { if (limit !== null) {
params.append('limit', String(limit)); params.append('limit', String(limit));
@@ -69,13 +73,13 @@ export const api = {
const queryString = params.toString(); const queryString = params.toString();
return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`); return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`);
}, },
renameProject: (projectName, displayName) => renameProject: (projectId, displayName) =>
authenticatedFetch(`/api/projects/${projectName}/rename`, { authenticatedFetch(`/api/projects/${projectId}/rename`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ displayName }), body: JSON.stringify({ displayName }),
}), }),
deleteSession: (projectName, sessionId) => deleteSession: (projectId, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, { authenticatedFetch(`/api/projects/${projectId}/sessions/${sessionId}`, {
method: 'DELETE', method: 'DELETE',
}), }),
renameSession: (sessionId, summary, provider) => renameSession: (sessionId, summary, provider) =>
@@ -91,12 +95,12 @@ export const api = {
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, { authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
method: 'DELETE', method: 'DELETE',
}), }),
deleteProject: (projectName, force = false, deleteData = false) => { deleteProject: (projectId, force = false, deleteData = false) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (force) params.set('force', 'true'); if (force) params.set('force', 'true');
if (deleteData) params.set('deleteData', 'true'); if (deleteData) params.set('deleteData', 'true');
const qs = params.toString(); const qs = params.toString();
return authenticatedFetch(`/api/projects/${projectName}${qs ? `?${qs}` : ''}`, { return authenticatedFetch(`/api/projects/${projectId}${qs ? `?${qs}` : ''}`, {
method: 'DELETE', method: 'DELETE',
}); });
}, },
@@ -111,62 +115,62 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(workspaceData), body: JSON.stringify(workspaceData),
}), }),
readFile: (projectName, filePath) => readFile: (projectId, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`), authenticatedFetch(`/api/projects/${projectId}/file?filePath=${encodeURIComponent(filePath)}`),
readFileBlob: (projectName, filePath) => readFileBlob: (projectId, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/files/content?path=${encodeURIComponent(filePath)}`), authenticatedFetch(`/api/projects/${projectId}/files/content?path=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) => saveFile: (projectId, filePath, content) =>
authenticatedFetch(`/api/projects/${projectName}/file`, { authenticatedFetch(`/api/projects/${projectId}/file`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ filePath, content }), body: JSON.stringify({ filePath, content }),
}), }),
getFiles: (projectName, options = {}) => getFiles: (projectId, options = {}) =>
authenticatedFetch(`/api/projects/${projectName}/files`, options), authenticatedFetch(`/api/projects/${projectId}/files`, options),
// File operations // File operations
createFile: (projectName, { path, type, name }) => createFile: (projectId, { path, type, name }) =>
authenticatedFetch(`/api/projects/${projectName}/files/create`, { authenticatedFetch(`/api/projects/${projectId}/files/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ path, type, name }), body: JSON.stringify({ path, type, name }),
}), }),
renameFile: (projectName, { oldPath, newName }) => renameFile: (projectId, { oldPath, newName }) =>
authenticatedFetch(`/api/projects/${projectName}/files/rename`, { authenticatedFetch(`/api/projects/${projectId}/files/rename`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ oldPath, newName }), body: JSON.stringify({ oldPath, newName }),
}), }),
deleteFile: (projectName, { path, type }) => deleteFile: (projectId, { path, type }) =>
authenticatedFetch(`/api/projects/${projectName}/files`, { authenticatedFetch(`/api/projects/${projectId}/files`, {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ path, type }), body: JSON.stringify({ path, type }),
}), }),
uploadFiles: (projectName, formData) => uploadFiles: (projectId, formData) =>
authenticatedFetch(`/api/projects/${projectName}/files/upload`, { authenticatedFetch(`/api/projects/${projectId}/files/upload`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: {}, // Let browser set Content-Type for FormData headers: {}, // Let browser set Content-Type for FormData
}), }),
// TaskMaster endpoints // TaskMaster endpoints — all addressed by DB projectId post-migration.
taskmaster: { taskmaster: {
// Initialize TaskMaster in a project // Initialize TaskMaster in a project
init: (projectName) => init: (projectId) =>
authenticatedFetch(`/api/taskmaster/init/${projectName}`, { authenticatedFetch(`/api/taskmaster/init/${projectId}`, {
method: 'POST', method: 'POST',
}), }),
// Add a new task // Add a new task
addTask: (projectName, { prompt, title, description, priority, dependencies }) => addTask: (projectId, { prompt, title, description, priority, dependencies }) =>
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, { authenticatedFetch(`/api/taskmaster/add-task/${projectId}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ prompt, title, description, priority, dependencies }), body: JSON.stringify({ prompt, title, description, priority, dependencies }),
}), }),
// Parse PRD to generate tasks // Parse PRD to generate tasks
parsePRD: (projectName, { fileName, numTasks, append }) => parsePRD: (projectId, { fileName, numTasks, append }) =>
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, { authenticatedFetch(`/api/taskmaster/parse-prd/${projectId}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ fileName, numTasks, append }), body: JSON.stringify({ fileName, numTasks, append }),
}), }),
@@ -176,15 +180,15 @@ export const api = {
authenticatedFetch('/api/taskmaster/prd-templates'), authenticatedFetch('/api/taskmaster/prd-templates'),
// Apply a PRD template // Apply a PRD template
applyTemplate: (projectName, { templateId, fileName, customizations }) => applyTemplate: (projectId, { templateId, fileName, customizations }) =>
authenticatedFetch(`/api/taskmaster/apply-template/${projectName}`, { authenticatedFetch(`/api/taskmaster/apply-template/${projectId}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ templateId, fileName, customizations }), body: JSON.stringify({ templateId, fileName, customizations }),
}), }),
// Update a task // Update a task
updateTask: (projectName, taskId, updates) => updateTask: (projectId, taskId, updates) =>
authenticatedFetch(`/api/taskmaster/update-task/${projectName}/${taskId}`, { authenticatedFetch(`/api/taskmaster/update-task/${projectId}/${taskId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(updates), body: JSON.stringify(updates),
}), }),