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

@@ -135,7 +135,9 @@ export function useChatComposerState({
}: UseChatComposerStateArgs) {
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
// Draft inputs are keyed by the DB projectId so per-project drafts
// survive display-name changes.
return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
}
return '';
});
@@ -276,9 +278,11 @@ export function useChatComposerState({
const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
// The `/api/commands/execute` context sends `projectId` now instead of
// a folder-derived project name; the path is still included verbatim.
const context = {
projectPath: selectedProject.fullPath || selectedProject.path,
projectName: selectedProject.name,
projectId: selectedProject.projectId,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
@@ -503,7 +507,7 @@ export function useChatComposerState({
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
method: 'POST',
headers: {},
body: formData,
@@ -669,7 +673,7 @@ export function useChatComposerState({
textareaRef.current.style.height = 'auto';
}
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
},
[
selectedSession,
@@ -712,22 +716,22 @@ export function useChatComposerState({
if (!selectedProject) {
return;
}
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
setInput((previous) => {
const next = previous === savedInput ? previous : savedInput;
inputValueRef.current = next;
return next;
});
}, [selectedProject?.name]);
}, [selectedProject?.projectId]);
useEffect(() => {
if (!selectedProject) {
return;
}
if (input !== '') {
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input);
} else {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
}
}, [input, selectedProject]);

View File

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

View File

@@ -59,16 +59,18 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
const abortController = new AbortController();
const fetchProjectFiles = async () => {
const projectName = selectedProject?.name;
// File list is keyed by DB projectId now; the backend resolves it to
// the project's path before reading.
const projectId = selectedProject?.projectId;
setFileList([]);
setFilteredFiles([]);
if (!projectName) {
if (!projectId) {
return;
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
const response = await api.getFiles(projectId, { signal: abortController.signal });
if (!response.ok) {
return;
}
@@ -88,7 +90,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
return () => {
abortController.abort();
};
}, [selectedProject?.name]);
}, [selectedProject?.projectId]);
useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition);

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

View File

@@ -212,7 +212,8 @@ function ChatInterface({
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as LLMProvider,
projectName: selectedProject.name,
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
projectId: selectedProject.projectId,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
setIsLoading(false);

View File

@@ -23,7 +23,10 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const fileProjectName = file.projectName ?? projectPath;
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
// the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier.
const fileProjectId = file.projectId ?? projectPath;
const filePath = file.path;
const fileName = file.name;
const fileDiffNewString = file.diffInfo?.new_string;
@@ -49,11 +52,11 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
return;
}
if (!fileProjectName) {
if (!fileProjectId) {
throw new Error('Missing project identifier');
}
const response = await api.readFile(fileProjectName, filePath);
const response = await api.readFile(fileProjectId, filePath);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
@@ -70,18 +73,18 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
};
loadFileContent();
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
try {
if (!fileProjectName) {
if (!fileProjectId) {
throw new Error('Missing project identifier');
}
const response = await api.saveFile(fileProjectName, filePath, content);
const response = await api.saveFile(fileProjectId, filePath, content);
if (!response.ok) {
const contentType = response.headers.get('content-type');
@@ -106,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectName]);
}, [content, filePath, fileProjectId]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });

View File

@@ -29,11 +29,13 @@ export const useEditorSidebar = ({
setEditingFile({
name: fileName,
path: filePath,
projectName: selectedProject?.name,
// DB projectId is forwarded to the editor so it can read/save files
// via `/api/projects/:projectId/file` endpoints.
projectId: selectedProject?.projectId,
diffInfo,
});
},
[selectedProject?.name],
[selectedProject?.projectId],
);
const handleCloseEditor = useCallback(() => {

View File

@@ -7,7 +7,9 @@ export type CodeEditorDiffInfo = {
export type CodeEditorFile = {
name: string;
path: string;
projectName?: string;
// DB projectId; used by the editor to build `/api/projects/:projectId/file`
// URLs for reading and saving content.
projectId?: string;
diffInfo?: CodeEditorDiffInfo | null;
[key: string]: unknown;
};

View File

@@ -20,9 +20,11 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
}, []);
useEffect(() => {
const projectName = selectedProject?.name;
// File-tree requests use the DB projectId; the backend resolves it to the
// project's absolute path through the projects table.
const projectId = selectedProject?.projectId;
if (!projectName) {
if (!projectId) {
setFiles([]);
setLoading(false);
return;
@@ -42,7 +44,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
const response = await api.getFiles(projectId, { signal: abortControllerRef.current!.signal });
if (!response.ok) {
const errorText = await response.text();
@@ -79,7 +81,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
isActive = false;
abortControllerRef.current?.abort();
};
}, [selectedProject?.name, refreshKey]);
}, [selectedProject?.projectId, refreshKey]);
return {
files,

View File

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

View File

@@ -154,7 +154,8 @@ export const useFileTreeUpload = ({
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
`/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,
// File upload endpoint is keyed by DB projectId post-migration.
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
formData
);

View File

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

View File

@@ -101,7 +101,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name,
// Image URL uses the DB projectId so ImageViewer can hit the
// /api/projects/:projectId/files/content endpoint directly.
projectId: selectedProject.projectId,
});
return;
}

View File

@@ -10,7 +10,7 @@ type ImageViewerProps = {
};
export default function ImageViewer({ file, onClose }: ImageViewerProps) {
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
const imagePath = `/api/projects/${file.projectId}/files/content?path=${encodeURIComponent(file.path)}`;
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

View File

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

View File

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

View File

@@ -58,7 +58,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
});
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
projectName: selectedProject?.name ?? null,
// `projectId` (DB primary key) is forwarded to the revert API which uses it
// as the `project` body param.
projectId: selectedProject?.projectId ?? null,
onSuccess: refreshAll,
});

View File

@@ -73,13 +73,15 @@ function MainContent({
});
useEffect(() => {
const selectedProjectName = selectedProject?.name;
const currentProjectName = currentProject?.name;
// Identify projects by DB `projectId`; the TaskMaster context uses the
// same identifier to key its internal maps.
const selectedProjectId = selectedProject?.projectId;
const currentProjectId = currentProject?.projectId;
if (selectedProject && selectedProjectName !== currentProjectName) {
if (selectedProject && selectedProjectId !== currentProjectId) {
setCurrentProject?.(selectedProject);
}
}, [selectedProject, currentProject?.name, setCurrentProject]);
}, [selectedProject, currentProject?.projectId, setCurrentProject]);
useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') {

View File

@@ -128,7 +128,8 @@ export function useMcpServerForm({
currentProjects
.map((project) => ({
value: getProjectPath(project),
label: project.displayName || project.name,
// Fall back to projectId (DB primary key) when no display name is set.
label: project.displayName || project.projectId,
}))
.filter((project) => project.value)
), [currentProjects]);

View File

@@ -31,6 +31,8 @@ type GlobalMcpServerResponse = {
results: GlobalMcpServerResult[];
};
// Internal MCP-side shape; `name` is now filled from the DB projectId since
// the legacy Project.name field was removed during the projectId migration.
type ProjectTarget = {
name: string;
displayName: string;
@@ -111,6 +113,9 @@ const normalizeServer = (
bearerTokenEnvVar: server.bearerTokenEnvVar,
envHttpHeaders: server.envHttpHeaders ?? {},
workspacePath: project?.path || server.workspacePath,
// Keep the `projectName` key in the MCP wire payload for backwards
// compatibility. ProjectTarget.name is populated from the DB `projectId`
// (see createProjectTargets) so this still carries the new identifier.
projectName: project?.name || server.projectName,
projectDisplayName: project?.displayName || server.projectDisplayName,
};
@@ -126,8 +131,9 @@ const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
seen.add(projectPath);
acc.push({
name: project.name,
displayName: project.displayName || project.name,
// Use projectId as the stable internal identifier.
name: project.projectId,
displayName: project.displayName || project.projectId,
path: projectPath,
});
return acc;

View File

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

View File

@@ -12,6 +12,9 @@ type PluginTabContentProps = {
type PluginContext = {
theme: 'dark' | 'light';
// Plugin contract historically used `name` for the project identifier; we
// keep that key and populate it from the DB `projectId` so external plugins
// continue to receive a stable opaque id.
project: { name: string; path: string } | null;
session: { id: string; title: string } | null;
};
@@ -25,7 +28,7 @@ function buildContext(
theme: isDarkMode ? 'dark' : 'light',
project: selectedProject
? {
name: selectedProject.name,
name: selectedProject.projectId,
path: selectedProject.fullPath || selectedProject.path || '',
}
: null,

View File

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

View File

@@ -73,7 +73,7 @@ export function usePrdDocument({
return;
}
if (!file?.projectName || !file?.path) {
if (!file?.projectId || !file?.path) {
if (!isMounted) {
return;
}
@@ -87,7 +87,8 @@ export function usePrdDocument({
try {
setLoading(true);
const response = await api.readFile(file.projectName, file.path);
// readFile uses the DB projectId to resolve the project's path server-side.
const response = await api.readFile(file.projectId, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,9 @@ type ConversationSession = {
};
type ConversationProjectResult = {
// Emitted by server/projects.js#searchConversations so the sidebar can map a
// match back to the Project in its current state by projectId.
projectId: string | null;
projectName: string;
projectDisplayName: string;
sessions: ConversationSession[];
@@ -69,7 +72,8 @@ type UseSidebarControllerArgs = {
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void;
onSessionDelete?: (sessionId: string) => void;
onProjectDelete?: (projectName: string) => void;
// `projectId` is the DB-assigned identifier; callbacks use that post-migration.
onProjectDelete?: (projectId: string) => void;
setCurrentProject: (project: Project) => void;
setSidebarVisible: (visible: boolean) => void;
sidebarVisible: boolean;
@@ -135,13 +139,15 @@ export function useSidebarController({
}, [projects]);
useEffect(() => {
// Expanded-project tracking is now keyed by the DB `projectId` so state
// survives display-name edits and other mutations.
if (selectedProject) {
setExpandedProjects((prev) => {
if (prev.has(selectedProject.name)) {
if (prev.has(selectedProject.projectId)) {
return prev;
}
const next = new Set(prev);
next.add(selectedProject.name);
next.add(selectedProject.projectId);
return next;
});
}
@@ -152,7 +158,7 @@ export function useSidebarController({
const loadedProjects = new Set<string>();
projects.forEach((project) => {
if (project.sessions && project.sessions.length >= 0) {
loadedProjects.add(project.name);
loadedProjects.add(project.projectId);
}
});
setInitialSessionsLoaded(loadedProjects);
@@ -296,30 +302,34 @@ export function useSidebarController({
[],
);
const toggleProject = useCallback((projectName: string) => {
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
// `projectId` as their identifier after the migration.
const toggleProject = useCallback((projectId: string) => {
setExpandedProjects((prev) => {
const next = new Set<string>();
if (!prev.has(projectName)) {
next.add(projectName);
if (!prev.has(projectId)) {
next.add(projectId);
}
return next;
});
}, []);
const handleSessionClick = useCallback(
(session: SessionWithProvider, projectName: string) => {
onSessionSelect({ ...session, __projectName: projectName });
(session: SessionWithProvider, projectId: string) => {
// Tag the session with its owning projectId so downstream handlers
// can correlate it with the selectedProject in the app state.
onSessionSelect({ ...session, __projectId: projectId });
},
[onSessionSelect],
);
const toggleStarProject = useCallback((projectName: string) => {
const toggleStarProject = useCallback((projectId: string) => {
setStarredProjects((prev) => {
const next = new Set(prev);
if (next.has(projectName)) {
next.delete(projectName);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectName);
next.add(projectId);
}
persistStarredProjects(next);
@@ -328,7 +338,7 @@ export function useSidebarController({
}, []);
const isProjectStarred = useCallback(
(projectName: string) => starredProjects.has(projectName),
(projectId: string) => starredProjects.has(projectId),
[starredProjects],
);
@@ -340,7 +350,8 @@ export function useSidebarController({
const projectsWithSessionMeta = useMemo(
() =>
projects.map((project) => {
const hasMoreOverride = projectHasMoreOverrides[project.name];
// The `hasMore` override map is keyed by projectId (see loadMoreSessions).
const hasMoreOverride = projectHasMoreOverrides[project.projectId];
if (hasMoreOverride === undefined) {
return project;
}
@@ -364,7 +375,9 @@ export function useSidebarController({
);
const startEditing = useCallback((project: Project) => {
setEditingProject(project.name);
// `editingProject` is keyed by projectId so it stays stable across
// display-name mutations that happen while the input is open.
setEditingProject(project.projectId);
setEditingName(project.displayName);
}, []);
@@ -374,9 +387,11 @@ export function useSidebarController({
}, []);
const saveProjectName = useCallback(
async (projectName: string) => {
// `projectId` is the DB primary key; the rename API resolves the path
// through the `projects` table before writing the new display name.
async (projectId: string) => {
try {
const response = await api.renameProject(projectName, editingName);
const response = await api.renameProject(projectId, editingName);
if (response.ok) {
if (window.refreshProjects) {
await window.refreshProjects();
@@ -397,13 +412,15 @@ export function useSidebarController({
);
const showDeleteSessionConfirmation = useCallback(
// `projectId` (not the legacy folder-encoded name) is what the DELETE
// /api/projects/:projectId/sessions/:sessionId endpoint expects.
(
projectName: string,
projectId: string,
sessionId: string,
sessionTitle: string,
provider: SessionDeleteConfirmation['provider'] = 'claude',
) => {
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider });
},
[],
);
@@ -413,7 +430,7 @@ export function useSidebarController({
return;
}
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
const { projectId, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try {
@@ -423,7 +440,8 @@ export function useSidebarController({
} else if (provider === 'gemini') {
response = await api.deleteGeminiSession(sessionId);
} else {
response = await api.deleteSession(projectName, sessionId);
// Claude sessions are owned by the DB project row; pass projectId.
response = await api.deleteSession(projectId, sessionId);
}
if (response.ok) {
@@ -461,13 +479,15 @@ export function useSidebarController({
const isEmpty = sessionCount === 0;
setDeleteConfirmation(null);
setDeletingProjects((prev) => new Set([...prev, project.name]));
// Track in-flight deletes by projectId so the UI can disable actions
// even if the project object is rebuilt while the request is flying.
setDeletingProjects((prev) => new Set([...prev, project.projectId]));
try {
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
const response = await api.deleteProject(project.projectId, !isEmpty, deleteData);
if (response.ok) {
onProjectDelete?.(project.name);
onProjectDelete?.(project.projectId);
} else {
const error = (await response.json()) as { error?: string };
alert(error.error || t('messages.deleteProjectFailed'));
@@ -478,7 +498,7 @@ export function useSidebarController({
} finally {
setDeletingProjects((prev) => {
const next = new Set(prev);
next.delete(project.name);
next.delete(project.projectId);
return next;
});
}
@@ -486,19 +506,21 @@ export function useSidebarController({
const loadMoreSessions = useCallback(
async (project: Project) => {
const hasMoreOverride = projectHasMoreOverrides[project.name];
// Per-project bookkeeping (additionalSessions, loadingSessions,
// projectHasMoreOverrides) is indexed by the DB `projectId`.
const hasMoreOverride = projectHasMoreOverrides[project.projectId];
const canLoadMore =
hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true;
if (!canLoadMore || loadingSessions[project.name]) {
if (!canLoadMore || loadingSessions[project.projectId]) {
return;
}
setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));
setLoadingSessions((prev) => ({ ...prev, [project.projectId]: true }));
try {
const currentSessionCount =
(project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await api.sessions(project.name, 5, currentSessionCount);
(project.sessions?.length || 0) + (additionalSessions[project.projectId]?.length || 0);
const response = await api.sessions(project.projectId, 5, currentSessionCount);
if (!response.ok) {
return;
@@ -511,17 +533,17 @@ export function useSidebarController({
setAdditionalSessions((prev) => ({
...prev,
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
[project.projectId]: [...(prev[project.projectId] || []), ...(result.sessions || [])],
}));
if (result.hasMore === false) {
// Keep hasMore state in local hook state instead of mutating the project prop object.
setProjectHasMoreOverrides((prev) => ({ ...prev, [project.name]: false }));
setProjectHasMoreOverrides((prev) => ({ ...prev, [project.projectId]: false }));
}
} catch (error) {
console.error('Error loading more sessions:', error);
} finally {
setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
setLoadingSessions((prev) => ({ ...prev, [project.projectId]: false }));
}
},
[additionalSessions, loadingSessions, projectHasMoreOverrides],
@@ -545,7 +567,9 @@ export function useSidebarController({
}, [onRefresh]);
const updateSessionSummary = useCallback(
async (_projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
// `_projectId` is unused by the rename endpoint but preserved in the
// callback signature so existing wiring from sidebar components works.
async (_projectId: string, sessionId: string, summary: string, provider: LLMProvider) => {
const trimmed = summary.trim();
if (!trimmed) {
setEditingSession(null);

View File

@@ -14,8 +14,10 @@ export type DeleteProjectConfirmation = {
sessionCount: number;
};
// Delete confirmation payload; `projectId` is the DB primary key used by the
// DELETE /api/projects/:projectId/sessions/:sessionId endpoint.
export type SessionDeleteConfirmation = {
projectName: string;
projectId: string;
sessionId: string;
sessionTitle: string;
provider: LLMProvider;
@@ -29,7 +31,9 @@ export type SidebarProps = {
onSessionSelect: (session: ProjectSession) => void;
onNewSession: (project: Project) => void;
onSessionDelete?: (sessionId: string) => void;
onProjectDelete?: (projectName: string) => void;
// `projectId` is the DB identifier; the sidebar hands it back to the parent
// when the delete flow completes.
onProjectDelete?: (projectId: string) => void;
isLoading: boolean;
loadingProgress: LoadingProgress | null;
onRefresh: () => Promise<void> | void;
@@ -55,4 +59,11 @@ export type MCPServerStatus = {
isConfigured?: boolean;
} | 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,
additionalSessions: AdditionalSessionsByProject,
): SessionWithProvider[] => {
// `additionalSessions` is indexed by DB `projectId` now (the sidebar keys
// every per-project map by the same identifier).
const claudeSessions = [
...(project.sessions || []),
...(additionalSessions[project.name] || []),
...(additionalSessions[project.projectId] || []),
].map((session) => ({ ...session, __provider: 'claude' as const }));
const cursorSessions = (project.cursorSessions || []).map((session) => ({
@@ -151,8 +153,9 @@ export const sortProjects = (
const byName = [...projects];
byName.sort((projectA, projectB) => {
const aStarred = starredProjects.has(projectA.name);
const bStarred = starredProjects.has(projectB.name);
// Starred projects are tracked by `projectId` in localStorage.
const aStarred = starredProjects.has(projectA.projectId);
const bStarred = starredProjects.has(projectB.projectId);
if (aStarred && !bStarred) {
return -1;
@@ -169,7 +172,7 @@ export const sortProjects = (
);
}
return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);
return (projectA.displayName || projectA.projectId).localeCompare(projectB.displayName || projectB.projectId);
});
return byName;
@@ -182,9 +185,11 @@ export const filterProjects = (projects: Project[], searchFilter: string): Proje
}
return projects.filter((project) => {
const displayName = (project.displayName || project.name).toLowerCase();
const projectName = project.name.toLowerCase();
return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);
const displayName = (project.displayName || project.projectId).toLowerCase();
// `project.path`/`fullPath` is the most useful search target now that the
// folder-derived name is gone; fall back to displayName above.
const searchPath = (project.path || project.fullPath || '').toLowerCase();
return displayName.includes(normalizedSearch) || searchPath.includes(normalizedSearch);
});
};
@@ -218,12 +223,14 @@ export const normalizeProjectForSettings = (project: Project): SettingsProject =
? project.path
: '';
// Legacy SettingsProject still expects a `name` field; use the projectId so
// downstream consumers that rely on a stable identifier continue to work.
return {
name: project.name,
name: project.projectId,
displayName:
typeof project.displayName === 'string' && project.displayName.trim().length > 0
? project.displayName
: project.name,
: project.projectId,
fullPath: fallbackPath,
path:
typeof project.path === 'string' && project.path.length > 0

View File

@@ -234,14 +234,18 @@ function Sidebar({
conversationResults={conversationResults}
isSearching={isSearching}
searchProgress={searchProgress}
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
// `projectId` (DB key) is the canonical identifier post-migration.
// The server emits null when it can't resolve a project row for
// the search hit; treat that as "no project" and still navigate
// to the session so the user can open it from the URL.
const resolvedProvider = (provider || 'claude') as LLMProvider;
const project = projects.find(p => p.name === projectName);
const project = projectId ? projects.find(p => p.projectId === projectId) : null;
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
const sessionObj = {
id: sessionId,
__provider: resolvedProvider,
__projectName: projectName,
__projectId: projectId ?? undefined,
...searchTarget,
};
if (project) {
@@ -249,12 +253,12 @@ function Sidebar({
const sessions = getProjectSessions(project);
const existing = sessions.find(s => s.id === sessionId);
if (existing) {
handleSessionClick({ ...existing, ...searchTarget }, projectName);
handleSessionClick({ ...existing, ...searchTarget }, project.projectId);
} else {
handleSessionClick(sessionObj, projectName);
handleSessionClick(sessionObj, project.projectId);
}
} else {
handleSessionClick(sessionObj, projectName);
handleSessionClick(sessionObj, projectId ?? '');
}
}}
onRefresh={() => {

View File

@@ -48,7 +48,9 @@ type SidebarContentProps = {
conversationResults: ConversationSearchResults | null;
isSearching: boolean;
searchProgress: SearchProgress | null;
onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
// Conversation result clicks pass back the DB projectId (or null when the
// server couldn't resolve it). Consumers must handle the null case.
onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
onRefresh: () => void;
isRefreshing: boolean;
onCreateProject: () => void;
@@ -170,10 +172,12 @@ export default function SidebarContent({
</div>
{projectResult.sessions.map((session) => (
<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"
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.provider || session.matches[0]?.provider || 'claude',
session.matches[0]?.timestamp,

View File

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

View File

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

View File

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

View File

@@ -49,17 +49,19 @@ export default function SidebarSessionItem({
const sessionView = createSessionViewModel(session, currentTime, t);
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 = () => {
onProjectSelect(project);
onSessionSelect(session, project.name);
onSessionSelect(session, project.projectId);
};
const saveEditedSession = () => {
onSaveEditingSession(project.name, session.id, editingSessionName, session.__provider);
onSaveEditingSession(project.projectId, session.id, editingSessionName, session.__provider);
};
const requestDeleteSession = () => {
onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider);
onDeleteSession(project.projectId, session.id, sessionView.sessionName, session.__provider);
};
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',
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">
<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 [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 taskMasterRequestSeqRef = useRef(0);
useEffect(() => {
currentProjectNameRef.current = currentProject?.name ?? null;
}, [currentProject?.name]);
currentProjectIdRef.current = currentProject?.projectId ?? null;
}, [currentProject?.projectId]);
useEffect(() => {
projectTaskMasterRef.current = projectTaskMaster;
@@ -95,12 +97,14 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
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);
setProjects((previousProjects) =>
previousProjects.map((project) => {
if (project.name !== projectName) {
if (project.projectId !== projectId) {
return project;
}
@@ -112,7 +116,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
);
setCurrentProjectState((previousProject) => {
if (!previousProject || previousProject.name !== projectName) {
if (!previousProject || previousProject.projectId !== projectId) {
return previousProject;
}
@@ -124,15 +128,15 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, []);
const refreshCurrentProjectTaskMaster = useCallback(
async (projectName: string) => {
if (!projectName || !user || !token) {
async (projectId: string) => {
if (!projectId || !user || !token) {
return;
}
const requestSequence = ++taskMasterRequestSeqRef.current;
try {
const response = await api.projectTaskmaster(projectName);
const response = await api.projectTaskmaster(projectId);
if (!response.ok) {
throw new Error(`Failed to fetch TaskMaster details: ${response.status}`);
}
@@ -142,16 +146,16 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
if (
requestSequence !== taskMasterRequestSeqRef.current
|| currentProjectNameRef.current !== projectName
|| currentProjectIdRef.current !== projectId
) {
return;
}
applyTaskMasterInfo(projectName, resolvedTaskMasterInfo);
applyTaskMasterInfo(projectId, resolvedTaskMasterInfo);
} catch (caughtError) {
if (
requestSequence !== taskMasterRequestSeqRef.current
|| currentProjectNameRef.current !== projectName
|| currentProjectIdRef.current !== projectId
) {
return;
}
@@ -172,12 +176,13 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
setTasks([]);
setNextTask(null);
if (!normalizedProject?.name) {
// `projectId` is the DB primary key used for every TaskMaster API call.
if (!normalizedProject?.projectId) {
taskMasterRequestSeqRef.current += 1;
return;
}
void refreshCurrentProjectTaskMaster(normalizedProject.name);
void refreshCurrentProjectTaskMaster(normalizedProject.projectId);
},
[refreshCurrentProjectTaskMaster],
);
@@ -206,14 +211,15 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
setProjects((previousProjects) => {
const taskMasterByProjectName = new Map(
// Cache is keyed by `projectId` (DB primary key) post-migration.
const taskMasterByProjectId = new Map(
previousProjects
.filter((project) => Boolean(project.taskmaster))
.map((project) => [project.name, project.taskmaster]),
.map((project) => [project.projectId, project.taskmaster]),
);
return enrichedProjects.map((project) => {
const cachedTaskMasterInfo = taskMasterByProjectName.get(project.name);
const cachedTaskMasterInfo = taskMasterByProjectId.get(project.projectId);
if (!cachedTaskMasterInfo) {
return project;
}
@@ -225,12 +231,12 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
});
});
const currentProjectName = currentProjectNameRef.current;
if (!currentProjectName) {
const currentProjectId = currentProjectIdRef.current;
if (!currentProjectId) {
return;
}
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;
const matchingProject = enrichedProjects.find((project) => project.projectId === currentProjectId) ?? null;
if (!matchingProject) {
taskMasterRequestSeqRef.current += 1;
@@ -252,7 +258,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
);
setProjectTaskMaster(cachedTaskMasterInfo);
void refreshCurrentProjectTaskMaster(currentProjectName);
void refreshCurrentProjectTaskMaster(currentProjectId);
} catch (caughtError) {
handleError('load projects', caughtError);
} finally {
@@ -261,9 +267,10 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, [clearError, handleError, refreshCurrentProjectTaskMaster, token, user]);
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([]);
setNextTask(null);
return;
@@ -273,7 +280,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
setIsLoadingTasks(true);
clearError();
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectId)}`);
if (!response.ok) {
const errorPayload = (await response.json()) as { message?: string };
throw new Error(errorPayload.message ?? 'Failed to load tasks');
@@ -291,7 +298,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
} finally {
setIsLoadingTasks(false);
}
}, [clearError, currentProject?.name, handleError, token, user]);
}, [clearError, currentProject?.projectId, handleError, token, user]);
const refreshMCPStatus = useCallback(async () => {
if (!user || !token) {
@@ -326,10 +333,10 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
useEffect(() => {
if (currentProject?.name && user && token) {
if (currentProject?.projectId && user && token) {
void refreshTasks();
}
}, [currentProject?.name, refreshTasks, token, user]);
}, [currentProject?.projectId, refreshTasks, token, user]);
useEffect(() => {
const message = latestMessage as TaskMasterWebSocketMessage | null;
@@ -337,15 +344,16 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
return;
}
if (message.type === 'taskmaster-project-updated' && message.projectName) {
if (message.projectName === currentProjectNameRef.current) {
void refreshCurrentProjectTaskMaster(message.projectName);
// Broadcasts now identify projects by `projectId` (see taskmaster-websocket.js).
if (message.type === 'taskmaster-project-updated' && message.projectId) {
if (message.projectId === currentProjectIdRef.current) {
void refreshCurrentProjectTaskMaster(message.projectId);
}
void refreshProjects();
return;
}
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {
if (message.type === 'taskmaster-tasks-updated' && message.projectId === currentProject?.projectId) {
void refreshTasks();
return;
}
@@ -353,7 +361,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
if (message.type === 'taskmaster-mcp-status-changed') {
void refreshMCPStatus();
}
}, [currentProject?.name, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]);
}, [currentProject?.projectId, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]);
const contextValue = useMemo<TaskMasterContextValue>(
() => ({

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
const [prdNotification, setPrdNotification] = useState<string | 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) => {
if (notificationTimeoutRef.current) {

View File

@@ -5,12 +5,16 @@
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
/**
* For empty shell instances where no project is provided,
* we use a default project object to ensure the shell can still function.
* For empty shell instances where no project is provided,
* we use a default project object to ensure the shell can still function.
* 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 = {
name: 'default',
projectId: 'default',
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',

View File

@@ -41,7 +41,7 @@ const projectsHaveChanges = (
}
const baseChanged =
nextProject.name !== prevProject.name ||
nextProject.projectId !== prevProject.projectId ||
nextProject.displayName !== prevProject.displayName ||
nextProject.fullPath !== prevProject.fullPath ||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
@@ -69,14 +69,16 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
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(
previousProjects
.filter((project) => Boolean(project.taskmaster))
.map((project) => [project.name, project.taskmaster]),
.map((project) => [project.projectId, project.taskmaster]),
);
return nextProjects.map((project) => {
const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.name);
const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.projectId);
if (!cachedTaskMasterInfo) {
return project;
}
@@ -107,8 +109,8 @@ const isUpdateAdditive = (
return true;
}
const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name);
const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name);
const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!currentSelectedProject || !updatedSelectedProject) {
return false;
@@ -214,13 +216,15 @@ export function useProjectsState({
await fetchProjects({ showLoadingState: false });
}, [fetchProjects]);
const hydrateProjectTaskMaster = useCallback(async (projectName: string) => {
if (!projectName) {
// Hydrates TaskMaster details for the given `projectId`. The project
// identifier comes directly from the DB-driven /api/projects response.
const hydrateProjectTaskMaster = useCallback(async (projectId: string) => {
if (!projectId) {
return;
}
try {
const response = await api.projectTaskmaster(projectName);
const response = await api.projectTaskmaster(projectId);
if (!response.ok) {
return;
}
@@ -233,14 +237,14 @@ export function useProjectsState({
setProjects((previousProjects) =>
previousProjects.map((project) =>
project.name === projectName
project.projectId === projectId
? { ...project, taskmaster: taskMasterInfo }
: project,
),
);
setSelectedProject((previousProject) => {
if (!previousProject || previousProject.name !== projectName) {
if (!previousProject || previousProject.projectId !== projectId) {
return previousProject;
}
@@ -250,7 +254,7 @@ export function useProjectsState({
};
});
} 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]);
useEffect(() => {
if (!selectedProject?.name) {
if (!selectedProject?.projectId) {
return;
}
void hydrateProjectTaskMaster(selectedProject.name);
}, [hydrateProjectTaskMaster, selectedProject?.name]);
void hydrateProjectTaskMaster(selectedProject.projectId);
}, [hydrateProjectTaskMaster, selectedProject?.projectId]);
// Auto-select the project when there is only one, so the user lands on the new session page
useEffect(() => {
@@ -345,7 +349,7 @@ export function useProjectsState({
}
const updatedSelectedProject = updatedProjects.find(
(project) => project.name === selectedProject.name,
(project) => project.projectId === selectedProject.projectId,
);
if (!updatedSelectedProject) {
@@ -383,10 +387,11 @@ export function useProjectsState({
return;
}
// Project membership is resolved through `projectId` after the migration.
for (const project of projects) {
const claudeSession = project.sessions?.find((session) => session.id === sessionId);
if (claudeSession) {
const shouldUpdateProject = selectedProject?.name !== project.name;
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
@@ -401,7 +406,7 @@ export function useProjectsState({
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
if (cursorSession) {
const shouldUpdateProject = selectedProject?.name !== project.name;
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
@@ -416,7 +421,7 @@ export function useProjectsState({
const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
if (codexSession) {
const shouldUpdateProject = selectedProject?.name !== project.name;
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
@@ -431,7 +436,7 @@ export function useProjectsState({
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
if (geminiSession) {
const shouldUpdateProject = selectedProject?.name !== project.name;
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
@@ -444,7 +449,7 @@ export function useProjectsState({
return;
}
}
}, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);
}, [sessionId, projects, selectedProject?.projectId, selectedSession?.id, selectedSession?.__provider]);
const handleProjectSelect = useCallback(
(project: Project) => {
@@ -473,17 +478,21 @@ export function useProjectsState({
}
if (isMobile) {
const sessionProjectName = session.__projectName;
const currentProjectName = selectedProject?.name;
// Sessions are tagged with the owning project's DB `projectId` when
// 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);
}
}
navigate(`/session/${session.id}`);
},
[activeTab, isMobile, navigate, selectedProject?.name],
[activeTab, isMobile, navigate, selectedProject?.projectId],
);
const handleNewSession = useCallback(
@@ -535,7 +544,7 @@ export function useProjectsState({
return;
}
const refreshedProject = mergedProjects.find((project) => project.name === selectedProject.name);
const refreshedProject = mergedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!refreshedProject) {
return;
}
@@ -568,17 +577,19 @@ export function useProjectsState({
}
}, [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(
(projectName: string) => {
if (selectedProject?.name === projectName) {
(projectId: string) => {
if (selectedProject?.projectId === projectId) {
setSelectedProject(null);
setSelectedSession(null);
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(

View File

@@ -165,12 +165,16 @@ export function useSessionStore() {
/**
* 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 (
sessionId: string,
opts: {
provider?: LLMProvider;
projectName?: string;
projectId?: string;
projectPath?: string;
limit?: number | null;
offset?: number;
@@ -183,7 +187,7 @@ export function useSessionStore() {
try {
const params = new URLSearchParams();
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.limit !== null && opts.limit !== undefined) {
params.append('limit', String(opts.limit));
@@ -224,12 +228,15 @@ export function useSessionStore() {
/**
* 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 (
sessionId: string,
opts: {
provider?: LLMProvider;
projectName?: string;
projectId?: string;
projectPath?: string;
limit?: number;
} = {},
@@ -239,7 +246,7 @@ export function useSessionStore() {
const params = new URLSearchParams();
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);
const limit = opts.limit ?? 20;
params.append('limit', String(limit));
@@ -299,12 +306,15 @@ export function useSessionStore() {
/**
* 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 (
sessionId: string,
opts: {
provider?: LLMProvider;
projectName?: string;
projectId?: string;
projectPath?: string;
} = {},
) => {
@@ -312,7 +322,7 @@ export function useSessionStore() {
try {
const params = new URLSearchParams();
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);
const qs = params.toString();

View File

@@ -13,7 +13,9 @@ export interface ProjectSession {
lastActivity?: string;
messageCount?: number;
__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;
}
@@ -30,8 +32,12 @@ export interface ProjectTaskmasterInfo {
[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 {
name: string;
projectId: string;
displayName: string;
fullPath: string;
path?: string;

View File

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