From 11fd54fcbbfb39f824bb4c908d428c20d801267c Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:34:04 +0530 Subject: [PATCH] fix(web): removing a project no longer opens a new thread for it (#15864) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/LegacySidebar.tsx | 2 + .../settings/ProjectSettingsPanel.test.tsx | 1 + .../settings/ProjectSettingsPanel.tsx | 3 +- apps/web/src/hooks/useHandleNewThread.test.ts | 55 ++++++++++++++----- apps/web/src/hooks/useHandleNewThread.ts | 9 ++- apps/web/src/hooks/useRemoveClonedProject.ts | 2 + apps/web/src/routes/_chat.index.tsx | 25 +++++++-- apps/web/src/state/entities.ts | 14 +++++ 8 files changed, 87 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 50de33c68fd3d629b327021435bb7c1e2ac1088a..b0f9eb7e1acd29126e9a8842af827081929a9974 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -91,6 +91,7 @@ import { useProjects, useThreadShells, useThreadShellsForProjectRefs, + waitForProjectRemoval, } from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -1542,6 +1543,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (result._tag === "Failure") { return result; } + await waitForProjectRemoval(memberProjectRef); const draftStore = useComposerDraftStore.getState(); releaseProjectDraftUploads( memberProjectRef, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx index 939bc4a043796434b7b7d2480651208600515692..79f6a35a8d2c1007be16b1d743ddd620b17aeb31 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.test.tsx @@ -76,6 +76,7 @@ vi.mock("~/state/environments", () => ({ vi.mock("~/state/entities", () => ({ useProjects: () => state.projects, useThreadShells: () => [], + waitForProjectRemoval: async () => true, })); vi.mock("~/state/projects", () => ({ projectEnvironment: { update: "update", delete: "delete" }, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index fcc83a87514770326496545bdb021b0944e6e5f9..2ad856e167129db68100c0bc2dfba58b60ecb4f3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -25,7 +25,7 @@ import { type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; -import { useThreadShells } from "../../state/entities"; +import { useThreadShells, waitForProjectRemoval } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { ProjectFavicon } from "../ProjectFavicon"; import { Alert, AlertDescription } from "../ui/alert"; @@ -395,6 +395,7 @@ function ProjectDetail({ return; } const projectRef = scopeProjectRef(member.environmentId, member.id); + await waitForProjectRemoval(projectRef); releaseProjectDraftUploads( projectRef, memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 12d13f3fb758f7e219e837fa03984f2c0163bb45..20198ac8f636801002dbb8cc6e7f91a9ae53f392 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -4,6 +4,7 @@ import type { RuntimeMode } from "@t3tools/contracts"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); + let projectRemoved = false; let targetSettings = { defaultThreadEnvMode: "local" as "local" | "worktree", newWorktreesStartFromOrigin: false, @@ -42,6 +43,12 @@ const testState = vi.hoisted(() => { get projectFileRead() { return projectFileRead; }, + get projectRemoved() { + return projectRemoved; + }, + removeProject() { + projectRemoved = true; + }, get targetSettings() { return targetSettings; }, @@ -53,6 +60,7 @@ const testState = vi.hoisted(() => { }, ) { storedDraft = nextStoredDraft; + projectRemoved = false; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, @@ -159,21 +167,23 @@ vi.mock("../logicalProject", () => ({ getProjectOrderKey: () => "remote-project", selectProjectGroupingSettings: () => ({}), })); -vi.mock("../state/entities", () => ({ - readProjects: () => [ - { - id: "project-remote", - environmentId: "environment-ssh", - workspaceRoot: "/remote/project", - defaultThreadEnvMode: null, - defaultModelSelection: null, - }, - ], - readThreadShell: () => null, - readThreadShells: () => [], - useProjects: () => [], - useThread: () => null, -})); +vi.mock("../state/entities", () => { + const project = { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }; + return { + readProject: () => (testState.projectRemoved ? null : project), + readProjects: () => (testState.projectRemoved ? [] : [project]), + readThreadShell: () => null, + readThreadShells: () => [], + useProjects: () => [], + useThread: () => null, + }; +}); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -238,6 +248,21 @@ describe.each([ expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + it("abandons a delayed draft open when the project is removed meanwhile", async () => { + testState.reset(draft); + const pendingOpen = useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { replace: true }, + ); + + testState.removeProject(); + testState.completeProjectFileRead(null); + + await expect(pendingOpen).resolves.toBeNull(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); + it.each([true, false])( "uses the target environment's start-from-origin default of %s", async (startFromOrigin) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0d8d365c432aac411c812138a30d45fec2110c25..9e9f4a7f3955a5ab5847cb668d564eac95d780b4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -24,6 +24,7 @@ import { } from "../logicalProject"; import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { + readProject, readProjects, readThreadShell, readThreadShells, @@ -134,6 +135,10 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + // Removal can land while the project file is read; a draft opened + // after that belongs to a project that no longer exists. + const abandonedSinceRequest = () => + routeChangedSinceRequest() || (project !== undefined && readProject(projectRef) === null); // The resolver applies project overrides and, until the server has // folded them, the aggregate's own legacy fields. const projectSettings = resolveProjectSettings( @@ -237,7 +242,7 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); - if (routeChangedSinceRequest()) { + if (abandonedSinceRequest()) { return null; } // The await yields. If the draft was opened (a concurrent @@ -376,7 +381,7 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); - if (routeChangedSinceRequest()) { + if (abandonedSinceRequest()) { return null; } // The await yields, so a concurrent invocation may have registered a diff --git a/apps/web/src/hooks/useRemoveClonedProject.ts b/apps/web/src/hooks/useRemoveClonedProject.ts index 72c5b91cde326e83d79d68b53e4683fd59540aa6..181fe2888fd9a121570d34e7092ac1020a38184b 100644 --- a/apps/web/src/hooks/useRemoveClonedProject.ts +++ b/apps/web/src/hooks/useRemoveClonedProject.ts @@ -6,6 +6,7 @@ import { useCallback } from "react"; import { useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; +import { waitForProjectRemoval } from "../state/entities"; import { projectEnvironment } from "../state/projects"; import { useAtomCommand } from "../state/use-atom-command"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -41,6 +42,7 @@ export function useRemoveClonedProject() { ); return false; } + await waitForProjectRemoval(projectRef); // Read the route after the await: the user may have moved on while the // delete was in flight, and only a draft of this project needs to go. const routeParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index b1f4343b385ae39ee6cf6f8ecda363d8269305f2..4d39869dbefcd09c1be5d7b4b84192d1c81d9f20 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -14,6 +14,7 @@ import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { + readProject, useAllEnvironmentShellsBootstrapped, useProjects, useThreadShells, @@ -64,13 +65,25 @@ function IndexDraftLanding() { if (mostRecentProject === null || startingRef.current || !isStillOnIndex()) { return; } + const projectRef = scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id); + // A project removed since this render is gone from the store; the next + // render picks another one. + if (readProject(projectRef) === null) { + return; + } startingRef.current = true; - void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), { - replace: true, - }).catch(() => { - startingRef.current = false; - setStartState((state) => ({ ...state, failed: true })); - }); + void handleNewThread(projectRef, { replace: true }) + .then((opened) => { + // The project was removed while its draft started; land on the next one. + if (opened === null) { + startingRef.current = false; + setStartState((state) => ({ ...state, retryRequest: state.retryRequest + 1 })); + } + }) + .catch(() => { + startingRef.current = false; + setStartState((state) => ({ ...state, failed: true })); + }); }, [handleNewThread, mostRecentProject, startState.retryRequest]); if (!bootstrapped) { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index ebf62f01be6fd22a1e74d8a2d8b8fc654002a80f..9e1c7f24d3e7b454ad4b53591ac6a778947c8549 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -194,6 +194,20 @@ export function waitForProject( }); } +/** + * Resolves once a deleted project leaves the live client store. The delete + * command returns before the shell stream does, so anything that picks a + * project in between (the index route's draft landing) still sees it. + */ +export function waitForProjectRemoval(ref: ScopedProjectRef, timeoutMs = 5_000): Promise { + return waitForAtomValue({ + registry: appAtomRegistry, + atom: environmentProjects.projectAtom(ref), + predicate: (project) => project === null, + timeoutMs, + }); +} + export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } -- 2.54.0