From 28e937a422a578b37c2368faacd1e5b8e58501f9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 5 Jun 2026 10:19:58 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=BB=20fix:=20Clear=20Project-Scoped=20?= =?UTF-8?q?Landing=20When=20the=20Selected=20Project=20Is=20Deleted=20(#13?= =?UTF-8?q?525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(projects): clear landing scope when the selected project is deleted When a project-scoped new-chat landing (/c/new?projectId=...) was open and the project got deleted, the chip kept showing the dead project and sends targeted it (saving unscoped with a visual glitch). - ChatRoute: only trust the scope when the project query succeeds (isSuccess), so React Query's retained-on-error data can't keep a deleted project's chip alive; strip ?projectId once the query settles to not-found so the landing reverts to a normal unscoped chat. - useDeleteProjectMutation: invalidate the project-detail query instead of removing it, so active observers refetch and settle into an error state (removing left them stuck loading under refetchOnMount: false). - e2e: regression test for delete-while-scoped. Fixes a follow-up issue to the projects feature (#13467). * fix(projects): only drop scope on definitive not-found; clear inactive deleted detail Address Codex review on #13525: - ChatRoute: gate scope removal on a 404 (isNotFoundError) or a success that resolves to a different/empty project, so a transient (non-404) failure under retry:false no longer unscopes a valid project; keep the chip through transient errors via retained data. - useDeleteProjectMutation: also removeQueries({ type: 'inactive' }) so a deleted project's inactive cached detail is dropped and a later visit refetches into a not-found state instead of rendering stale cache within cacheTime. --- .../src/data-provider/Projects/mutations.ts | 8 +++- client/src/routes/ChatRoute.tsx | 42 ++++++++++++++++++- e2e/specs/mock/projects.spec.ts | 27 ++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) 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');