diff --git a/client/src/data-provider/Projects/mutations.ts b/client/src/data-provider/Projects/mutations.ts index ab59af9cea..a02f177d97 100644 --- a/client/src/data-provider/Projects/mutations.ts +++ b/client/src/data-provider/Projects/mutations.ts @@ -65,7 +65,13 @@ export const useDeleteProjectMutation = (): UseMutationResult< return useMutation((projectId: string) => dataService.deleteProject(projectId), { onSuccess: (_result, projectId) => { clearActiveConversationProject(projectId); - queryClient.removeQueries([QueryKeys.project, projectId]); + // Invalidate so an *active* project-detail observer refetches and settles into a + // not-found state — consumers (e.g. ChatRoute) can then react to the deletion. + // (Removing it instead leaves observers stuck loading under `refetchOnMount: false`.) + queryClient.invalidateQueries([QueryKeys.project, projectId]); + // Drop any *inactive* cached detail so a later visit to the deleted project + // refetches (→ not-found) rather than rendering stale cache within `cacheTime`. + queryClient.removeQueries([QueryKeys.project, projectId], { type: 'inactive' }); queryClient.invalidateQueries([QueryKeys.projects]); queryClient.invalidateQueries([QueryKeys.allConversations]); }, diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index 65b0be91af..65eb82a40e 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -55,7 +55,7 @@ export default function ChatRoute() { useAppStartup({ startupConfig, user }); const index = 0; - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { conversationId = '' } = useParams(); const projectIdParam = searchParams.get('projectId'); const chatProjectId = isValidChatProjectId(projectIdParam) ? projectIdParam : null; @@ -70,12 +70,50 @@ export default function ChatRoute() { staleTime: 30000, cacheTime: 300000, }); - const verifiedChatProjectId = projectQuery.data?._id === chatProjectId ? chatProjectId : null; + /** + * The scoped project is *confirmed gone* — a not-found/not-owned (404) response, + * or a success that resolved to a different/empty project. Transient failures + * (500, network, auth refresh race) are deliberately excluded: this query runs with + * `retry: false`, so treating any error as "gone" would unscope a valid project on + * a single blip. + */ + const projectNotFound = projectQuery.isError && isNotFoundError(projectQuery.error); + /** + * Trust the scope when the project resolves to itself, and keep showing it through + * transient errors via React Query's retained data — but never for a project that + * is confirmed gone (otherwise the deleted project's chip lingers). + */ + const verifiedChatProjectId = + !projectNotFound && projectQuery.data?._id === chatProjectId ? chatProjectId : null; const projectTemplate = useMemo( () => (verifiedChatProjectId ? { chatProjectId: verifiedChatProjectId } : {}), [verifiedChatProjectId], ); + /** + * The scoped project is gone even though the URL still carries `?projectId`. Drop + * the param so the new-chat landing reverts to an unscoped chat — otherwise the + * stale chip lingers and sends target a dead project. + */ + const projectScopeMissing = + Boolean(chatProjectId) && + conversationId === Constants.NEW_CONVO && + (projectNotFound || (projectQuery.isSuccess && projectQuery.data?._id !== chatProjectId)); + + useEffect(() => { + if (!projectScopeMissing) { + return; + } + setSearchParams( + (params) => { + const next = new URLSearchParams(params); + next.delete('projectId'); + return next; + }, + { replace: true }, + ); + }, [projectScopeMissing, setSearchParams]); + const modelsQuery = useGetModelsQuery({ enabled: isAuthenticated, refetchOnMount: 'always', diff --git a/e2e/specs/mock/projects.spec.ts b/e2e/specs/mock/projects.spec.ts index 5462dd06fe..27ea2f9d39 100644 --- a/e2e/specs/mock/projects.spec.ts +++ b/e2e/specs/mock/projects.spec.ts @@ -85,6 +85,33 @@ test.describe('chat projects', () => { await expect(page).toHaveURL((url) => !url.searchParams.has('projectId')); }); + test('drops the project scope when the scoped project is deleted', async ({ page }) => { + test.setTimeout(90000); + const name = uniqueName('E2E Project'); + const projectId = await createProject(page, name); + + await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 }); + await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible(); + + // Delete the scoped project from the sidebar while it is selected on the landing. + const row = page.getByRole('button', { name, exact: true }).first(); + await expect(row).toBeVisible(); + const item = row.locator('..'); + await item.hover(); + await item.getByRole('button', { name: 'More options' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + // The stale chip is gone, the URL drops the now-dead project scope, and the + // composer reverts to an unscoped chat (placeholder no longer names the project). + await expect(page.getByRole('button', { name: 'Remove from project' })).toBeHidden(); + await expect(page).toHaveURL((url) => !url.searchParams.has('projectId')); + await expect(page.getByRole('textbox', { name: 'Message input' })).not.toHaveAttribute( + 'placeholder', + new RegExp(name), + ); + }); + test('switches the project via the chip combobox', async ({ page }) => { test.setTimeout(90000); const nameA = uniqueName('E2E Project A');