mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-04 20:05:38 +08:00
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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>(
|
||||
() => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' : '',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user