authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-14 08:34:04+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 08:34:04+05:30
log11fd54fcbbfb39f824bb4c908d428c20d801267c
tree0becd557f27cd05ea21db026c0723ea78094c1fd
parentaba3a6ddc0d2625739cb65c333f6052a4a35bc71
signature Signed by PGP key B5690EEEBB952194

fix(web): removing a project no longer opens a new thread for it (#15864)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 files changed, 87 insertions(+), 24 deletions(-)

apps/web/src/components/LegacySidebar.tsx+2
......@@ -91,6 +91,7 @@ import {
9191 useProjects,
9292 useThreadShells,
9393 useThreadShellsForProjectRefs,
94 waitForProjectRemoval,
9495} from "../state/entities";
9596import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
9697import { useThreadRunningTerminalIds } from "../state/terminalSessions";
......@@ -1542,6 +1543,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15421543 if (result._tag === "Failure") {
15431544 return result;
15441545 }
1546 await waitForProjectRemoval(memberProjectRef);
15451547 const draftStore = useComposerDraftStore.getState();
15461548 releaseProjectDraftUploads(
15471549 memberProjectRef,
apps/web/src/components/settings/ProjectSettingsPanel.test.tsx+1
......@@ -76,6 +76,7 @@ vi.mock("~/state/environments", () => ({
7676vi.mock("~/state/entities", () => ({
7777 useProjects: () => state.projects,
7878 useThreadShells: () => [],
79 waitForProjectRemoval: async () => true,
7980}));
8081vi.mock("~/state/projects", () => ({
8182 projectEnvironment: { update: "update", delete: "delete" },
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2-1
......@@ -25,7 +25,7 @@ import {
2525 type SidebarProjectSnapshot,
2626} from "../../sidebarProjectGrouping";
2727import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
28import { useThreadShells } from "../../state/entities";
28import { useThreadShells, waitForProjectRemoval } from "../../state/entities";
2929import { projectEnvironment } from "../../state/projects";
3030import { ProjectFavicon } from "../ProjectFavicon";
3131import { Alert, AlertDescription } from "../ui/alert";
......@@ -395,6 +395,7 @@ function ProjectDetail({
395395 return;
396396 }
397397 const projectRef = scopeProjectRef(member.environmentId, member.id);
398 await waitForProjectRemoval(projectRef);
398399 releaseProjectDraftUploads(
399400 projectRef,
400401 memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
apps/web/src/hooks/useHandleNewThread.test.ts+40-15
......@@ -4,6 +4,7 @@ import type { RuntimeMode } from "@t3tools/contracts";
44const testState = vi.hoisted(() => {
55 let completeProjectFileRead: (value: null) => void = () => undefined;
66 let projectFileRead = Promise.resolve<null>(null);
7 let projectRemoved = false;
78 let targetSettings = {
89 defaultThreadEnvMode: "local" as "local" | "worktree",
910 newWorktreesStartFromOrigin: false,
......@@ -42,6 +43,12 @@ const testState = vi.hoisted(() => {
4243 get projectFileRead() {
4344 return projectFileRead;
4445 },
46 get projectRemoved() {
47 return projectRemoved;
48 },
49 removeProject() {
50 projectRemoved = true;
51 },
4552 get targetSettings() {
4653 return targetSettings;
4754 },
......@@ -53,6 +60,7 @@ const testState = vi.hoisted(() => {
5360 },
5461 ) {
5562 storedDraft = nextStoredDraft;
63 projectRemoved = false;
5664 targetSettings = {
5765 defaultThreadEnvMode: workspaceDefaults.envMode,
5866 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
......@@ -159,21 +167,23 @@ vi.mock("../logicalProject", () => ({
159167 getProjectOrderKey: () => "remote-project",
160168 selectProjectGroupingSettings: () => ({}),
161169}));
162vi.mock("../state/entities", () => ({
163 readProjects: () => [
164 {
165 id: "project-remote",
166 environmentId: "environment-ssh",
167 workspaceRoot: "/remote/project",
168 defaultThreadEnvMode: null,
169 defaultModelSelection: null,
170 },
171 ],
172 readThreadShell: () => null,
173 readThreadShells: () => [],
174 useProjects: () => [],
175 useThread: () => null,
176}));
170vi.mock("../state/entities", () => {
171 const project = {
172 id: "project-remote",
173 environmentId: "environment-ssh",
174 workspaceRoot: "/remote/project",
175 defaultThreadEnvMode: null,
176 defaultModelSelection: null,
177 };
178 return {
179 readProject: () => (testState.projectRemoved ? null : project),
180 readProjects: () => (testState.projectRemoved ? [] : [project]),
181 readThreadShell: () => null,
182 readThreadShells: () => [],
183 useProjects: () => [],
184 useThread: () => null,
185 };
186});
177187vi.mock("../state/server", () => ({
178188 environmentServerConfigsAtom: {},
179189 primaryServerSettingsAtom: "primary-settings",
......@@ -238,6 +248,21 @@ describe.each([
238248 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
239249 });
240250
251 it("abandons a delayed draft open when the project is removed meanwhile", async () => {
252 testState.reset(draft);
253 const pendingOpen = useNewThreadHandler()(
254 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
255 { replace: true },
256 );
257
258 testState.removeProject();
259 testState.completeProjectFileRead(null);
260
261 await expect(pendingOpen).resolves.toBeNull();
262 expect(testState.router.navigate).not.toHaveBeenCalled();
263 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
264 });
265
241266 it.each([true, false])(
242267 "uses the target environment's start-from-origin default of %s",
243268 async (startFromOrigin) => {
apps/web/src/hooks/useHandleNewThread.ts+7-2
......@@ -24,6 +24,7 @@ import {
2424} from "../logicalProject";
2525import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
2626import {
27 readProject,
2728 readProjects,
2829 readThreadShell,
2930 readThreadShells,
......@@ -134,6 +135,10 @@ export function useNewThreadHandler() {
134135 candidate.id === projectRef.projectId &&
135136 candidate.environmentId === projectRef.environmentId,
136137 );
138 // Removal can land while the project file is read; a draft opened
139 // after that belongs to a project that no longer exists.
140 const abandonedSinceRequest = () =>
141 routeChangedSinceRequest() || (project !== undefined && readProject(projectRef) === null);
137142 // The resolver applies project overrides and, until the server has
138143 // folded them, the aggregate's own legacy fields.
139144 const projectSettings = resolveProjectSettings(
......@@ -237,7 +242,7 @@ export function useNewThreadHandler() {
237242 workspaceContext = pickExplicitWorkspaceOptions(options);
238243 } else if (!isDraftAlreadyOpen) {
239244 const defaultEnvMode = await resolveDefaultEnvMode();
240 if (routeChangedSinceRequest()) {
245 if (abandonedSinceRequest()) {
241246 return null;
242247 }
243248 // The await yields. If the draft was opened (a concurrent
......@@ -376,7 +381,7 @@ export function useNewThreadHandler() {
376381 const createdAt = new Date().toISOString();
377382 return (async () => {
378383 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
379 if (routeChangedSinceRequest()) {
384 if (abandonedSinceRequest()) {
380385 return null;
381386 }
382387 // The await yields, so a concurrent invocation may have registered a
apps/web/src/hooks/useRemoveClonedProject.ts+2
......@@ -6,6 +6,7 @@ import { useCallback } from "react";
66import { useComposerDraftStore } from "../composerDraftStore";
77import { resolveThreadRouteTarget } from "../threadRoutes";
88import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
9import { waitForProjectRemoval } from "../state/entities";
910import { projectEnvironment } from "../state/projects";
1011import { useAtomCommand } from "../state/use-atom-command";
1112import { stackedThreadToast, toastManager } from "../components/ui/toast";
......@@ -41,6 +42,7 @@ export function useRemoveClonedProject() {
4142 );
4243 return false;
4344 }
45 await waitForProjectRemoval(projectRef);
4446 // Read the route after the await: the user may have moved on while the
4547 // delete was in flight, and only a draft of this project needs to go.
4648 const routeParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
apps/web/src/routes/_chat.index.tsx+19-6
......@@ -14,6 +14,7 @@ import { SidebarInset } from "../components/ui/sidebar";
1414import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
1515import { useNewThreadHandler } from "../hooks/useHandleNewThread";
1616import {
17 readProject,
1718 useAllEnvironmentShellsBootstrapped,
1819 useProjects,
1920 useThreadShells,
......@@ -64,13 +65,25 @@ function IndexDraftLanding() {
6465 if (mostRecentProject === null || startingRef.current || !isStillOnIndex()) {
6566 return;
6667 }
68 const projectRef = scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id);
69 // A project removed since this render is gone from the store; the next
70 // render picks another one.
71 if (readProject(projectRef) === null) {
72 return;
73 }
6774 startingRef.current = true;
68 void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), {
69 replace: true,
70 }).catch(() => {
71 startingRef.current = false;
72 setStartState((state) => ({ ...state, failed: true }));
73 });
75 void handleNewThread(projectRef, { replace: true })
76 .then((opened) => {
77 // The project was removed while its draft started; land on the next one.
78 if (opened === null) {
79 startingRef.current = false;
80 setStartState((state) => ({ ...state, retryRequest: state.retryRequest + 1 }));
81 }
82 })
83 .catch(() => {
84 startingRef.current = false;
85 setStartState((state) => ({ ...state, failed: true }));
86 });
7487 }, [handleNewThread, mostRecentProject, startState.retryRequest]);
7588
7689 if (!bootstrapped) {
apps/web/src/state/entities.ts+14
......@@ -194,6 +194,20 @@ export function waitForProject(
194194 });
195195}
196196
197/**
198 * Resolves once a deleted project leaves the live client store. The delete
199 * command returns before the shell stream does, so anything that picks a
200 * project in between (the index route's draft landing) still sees it.
201 */
202export function waitForProjectRemoval(ref: ScopedProjectRef, timeoutMs = 5_000): Promise<boolean> {
203 return waitForAtomValue({
204 registry: appAtomRegistry,
205 atom: environmentProjects.projectAtom(ref),
206 predicate: (project) => project === null,
207 timeoutMs,
208 });
209}
210
197211export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
198212 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
199213}