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 {...@@ -91,6 +91,7 @@ import {
91 useProjects,91 useProjects,
92 useThreadShells,92 useThreadShells,
93 useThreadShellsForProjectRefs,93 useThreadShellsForProjectRefs,
94 waitForProjectRemoval,
94} from "../state/entities";95} from "../state/entities";
95import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";96import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
96import { useThreadRunningTerminalIds } from "../state/terminalSessions";97import { useThreadRunningTerminalIds } from "../state/terminalSessions";
...@@ -1542,6 +1543,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1542,6 +1543,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1542 if (result._tag === "Failure") {1543 if (result._tag === "Failure") {
1543 return result;1544 return result;
1544 }1545 }
1546 await waitForProjectRemoval(memberProjectRef);
1545 const draftStore = useComposerDraftStore.getState();1547 const draftStore = useComposerDraftStore.getState();
1546 releaseProjectDraftUploads(1548 releaseProjectDraftUploads(
1547 memberProjectRef,1549 memberProjectRef,
apps/web/src/components/settings/ProjectSettingsPanel.test.tsx+1
...@@ -76,6 +76,7 @@ vi.mock("~/state/environments", () => ({...@@ -76,6 +76,7 @@ vi.mock("~/state/environments", () => ({
76vi.mock("~/state/entities", () => ({76vi.mock("~/state/entities", () => ({
77 useProjects: () => state.projects,77 useProjects: () => state.projects,
78 useThreadShells: () => [],78 useThreadShells: () => [],
79 waitForProjectRemoval: async () => true,
79}));80}));
80vi.mock("~/state/projects", () => ({81vi.mock("~/state/projects", () => ({
81 projectEnvironment: { update: "update", delete: "delete" },82 projectEnvironment: { update: "update", delete: "delete" },
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2-1
...@@ -25,7 +25,7 @@ import {...@@ -25,7 +25,7 @@ import {
25 type SidebarProjectSnapshot,25 type SidebarProjectSnapshot,
26} from "../../sidebarProjectGrouping";26} from "../../sidebarProjectGrouping";
27import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";27import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
28import { useThreadShells } from "../../state/entities";28import { useThreadShells, waitForProjectRemoval } from "../../state/entities";
29import { projectEnvironment } from "../../state/projects";29import { projectEnvironment } from "../../state/projects";
30import { ProjectFavicon } from "../ProjectFavicon";30import { ProjectFavicon } from "../ProjectFavicon";
31import { Alert, AlertDescription } from "../ui/alert";31import { Alert, AlertDescription } from "../ui/alert";
...@@ -395,6 +395,7 @@ function ProjectDetail({...@@ -395,6 +395,7 @@ function ProjectDetail({
395 return;395 return;
396 }396 }
397 const projectRef = scopeProjectRef(member.environmentId, member.id);397 const projectRef = scopeProjectRef(member.environmentId, member.id);
398 await waitForProjectRemoval(projectRef);
398 releaseProjectDraftUploads(399 releaseProjectDraftUploads(
399 projectRef,400 projectRef,
400 memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),401 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";...@@ -4,6 +4,7 @@ import type { RuntimeMode } from "@t3tools/contracts";
4const testState = vi.hoisted(() => {4const testState = vi.hoisted(() => {
5 let completeProjectFileRead: (value: null) => void = () => undefined;5 let completeProjectFileRead: (value: null) => void = () => undefined;
6 let projectFileRead = Promise.resolve<null>(null);6 let projectFileRead = Promise.resolve<null>(null);
7 let projectRemoved = false;
7 let targetSettings = {8 let targetSettings = {
8 defaultThreadEnvMode: "local" as "local" | "worktree",9 defaultThreadEnvMode: "local" as "local" | "worktree",
9 newWorktreesStartFromOrigin: false,10 newWorktreesStartFromOrigin: false,
...@@ -42,6 +43,12 @@ const testState = vi.hoisted(() => {...@@ -42,6 +43,12 @@ const testState = vi.hoisted(() => {
42 get projectFileRead() {43 get projectFileRead() {
43 return projectFileRead;44 return projectFileRead;
44 },45 },
46 get projectRemoved() {
47 return projectRemoved;
48 },
49 removeProject() {
50 projectRemoved = true;
51 },
45 get targetSettings() {52 get targetSettings() {
46 return targetSettings;53 return targetSettings;
47 },54 },
...@@ -53,6 +60,7 @@ const testState = vi.hoisted(() => {...@@ -53,6 +60,7 @@ const testState = vi.hoisted(() => {
53 },60 },
54 ) {61 ) {
55 storedDraft = nextStoredDraft;62 storedDraft = nextStoredDraft;
63 projectRemoved = false;
56 targetSettings = {64 targetSettings = {
57 defaultThreadEnvMode: workspaceDefaults.envMode,65 defaultThreadEnvMode: workspaceDefaults.envMode,
58 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,66 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
...@@ -159,21 +167,23 @@ vi.mock("../logicalProject", () => ({...@@ -159,21 +167,23 @@ vi.mock("../logicalProject", () => ({
159 getProjectOrderKey: () => "remote-project",167 getProjectOrderKey: () => "remote-project",
160 selectProjectGroupingSettings: () => ({}),168 selectProjectGroupingSettings: () => ({}),
161}));169}));
162vi.mock("../state/entities", () => ({170vi.mock("../state/entities", () => {
163 readProjects: () => [171 const project = {
164 {172 id: "project-remote",
165 id: "project-remote",173 environmentId: "environment-ssh",
166 environmentId: "environment-ssh",174 workspaceRoot: "/remote/project",
167 workspaceRoot: "/remote/project",175 defaultThreadEnvMode: null,
168 defaultThreadEnvMode: null,176 defaultModelSelection: null,
169 defaultModelSelection: null,177 };
170 },178 return {
171 ],179 readProject: () => (testState.projectRemoved ? null : project),
172 readThreadShell: () => null,180 readProjects: () => (testState.projectRemoved ? [] : [project]),
173 readThreadShells: () => [],181 readThreadShell: () => null,
174 useProjects: () => [],182 readThreadShells: () => [],
175 useThread: () => null,183 useProjects: () => [],
176}));184 useThread: () => null,
185 };
186});
177vi.mock("../state/server", () => ({187vi.mock("../state/server", () => ({
178 environmentServerConfigsAtom: {},188 environmentServerConfigsAtom: {},
179 primaryServerSettingsAtom: "primary-settings",189 primaryServerSettingsAtom: "primary-settings",
...@@ -238,6 +248,21 @@ describe.each([...@@ -238,6 +248,21 @@ describe.each([
238 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();248 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
239 });249 });
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
241 it.each([true, false])(266 it.each([true, false])(
242 "uses the target environment's start-from-origin default of %s",267 "uses the target environment's start-from-origin default of %s",
243 async (startFromOrigin) => {268 async (startFromOrigin) => {
apps/web/src/hooks/useHandleNewThread.ts+7-2
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";25import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import {26import {
27 readProject,
27 readProjects,28 readProjects,
28 readThreadShell,29 readThreadShell,
29 readThreadShells,30 readThreadShells,
...@@ -134,6 +135,10 @@ export function useNewThreadHandler() {...@@ -134,6 +135,10 @@ export function useNewThreadHandler() {
134 candidate.id === projectRef.projectId &&135 candidate.id === projectRef.projectId &&
135 candidate.environmentId === projectRef.environmentId,136 candidate.environmentId === projectRef.environmentId,
136 );137 );
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);
137 // The resolver applies project overrides and, until the server has142 // The resolver applies project overrides and, until the server has
138 // folded them, the aggregate's own legacy fields.143 // folded them, the aggregate's own legacy fields.
139 const projectSettings = resolveProjectSettings(144 const projectSettings = resolveProjectSettings(
...@@ -237,7 +242,7 @@ export function useNewThreadHandler() {...@@ -237,7 +242,7 @@ export function useNewThreadHandler() {
237 workspaceContext = pickExplicitWorkspaceOptions(options);242 workspaceContext = pickExplicitWorkspaceOptions(options);
238 } else if (!isDraftAlreadyOpen) {243 } else if (!isDraftAlreadyOpen) {
239 const defaultEnvMode = await resolveDefaultEnvMode();244 const defaultEnvMode = await resolveDefaultEnvMode();
240 if (routeChangedSinceRequest()) {245 if (abandonedSinceRequest()) {
241 return null;246 return null;
242 }247 }
243 // The await yields. If the draft was opened (a concurrent248 // The await yields. If the draft was opened (a concurrent
...@@ -376,7 +381,7 @@ export function useNewThreadHandler() {...@@ -376,7 +381,7 @@ export function useNewThreadHandler() {
376 const createdAt = new Date().toISOString();381 const createdAt = new Date().toISOString();
377 return (async () => {382 return (async () => {
378 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());383 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
379 if (routeChangedSinceRequest()) {384 if (abandonedSinceRequest()) {
380 return null;385 return null;
381 }386 }
382 // The await yields, so a concurrent invocation may have registered a387 // 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";...@@ -6,6 +6,7 @@ import { useCallback } from "react";
6import { useComposerDraftStore } from "../composerDraftStore";6import { useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteTarget } from "../threadRoutes";7import { resolveThreadRouteTarget } from "../threadRoutes";
8import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";8import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
9import { waitForProjectRemoval } from "../state/entities";
9import { projectEnvironment } from "../state/projects";10import { projectEnvironment } from "../state/projects";
10import { useAtomCommand } from "../state/use-atom-command";11import { useAtomCommand } from "../state/use-atom-command";
11import { stackedThreadToast, toastManager } from "../components/ui/toast";12import { stackedThreadToast, toastManager } from "../components/ui/toast";
...@@ -41,6 +42,7 @@ export function useRemoveClonedProject() {...@@ -41,6 +42,7 @@ export function useRemoveClonedProject() {
41 );42 );
42 return false;43 return false;
43 }44 }
45 await waitForProjectRemoval(projectRef);
44 // Read the route after the await: the user may have moved on while the46 // Read the route after the await: the user may have moved on while the
45 // delete was in flight, and only a draft of this project needs to go.47 // delete was in flight, and only a draft of this project needs to go.
46 const routeParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};48 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";...@@ -14,6 +14,7 @@ import { SidebarInset } from "../components/ui/sidebar";
14import { WorkspacePageHeader } from "../components/WorkspacePageHeader";14import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
15import { useNewThreadHandler } from "../hooks/useHandleNewThread";15import { useNewThreadHandler } from "../hooks/useHandleNewThread";
16import {16import {
17 readProject,
17 useAllEnvironmentShellsBootstrapped,18 useAllEnvironmentShellsBootstrapped,
18 useProjects,19 useProjects,
19 useThreadShells,20 useThreadShells,
...@@ -64,13 +65,25 @@ function IndexDraftLanding() {...@@ -64,13 +65,25 @@ function IndexDraftLanding() {
64 if (mostRecentProject === null || startingRef.current || !isStillOnIndex()) {65 if (mostRecentProject === null || startingRef.current || !isStillOnIndex()) {
65 return;66 return;
66 }67 }
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 }
67 startingRef.current = true;74 startingRef.current = true;
68 void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), {75 void handleNewThread(projectRef, { replace: true })
69 replace: true,76 .then((opened) => {
70 }).catch(() => {77 // The project was removed while its draft started; land on the next one.
71 startingRef.current = false;78 if (opened === null) {
72 setStartState((state) => ({ ...state, failed: true }));79 startingRef.current = false;
73 });80 setStartState((state) => ({ ...state, retryRequest: state.retryRequest + 1 }));
81 }
82 })
83 .catch(() => {
84 startingRef.current = false;
85 setStartState((state) => ({ ...state, failed: true }));
86 });
74 }, [handleNewThread, mostRecentProject, startState.retryRequest]);87 }, [handleNewThread, mostRecentProject, startState.retryRequest]);
7588
76 if (!bootstrapped) {89 if (!bootstrapped) {
apps/web/src/state/entities.ts+14
...@@ -194,6 +194,20 @@ export function waitForProject(...@@ -194,6 +194,20 @@ export function waitForProject(
194 });194 });
195}195}
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
197export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {211export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
198 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));212 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
199}213}