authorgravatar for 45952064+bmdavis419@users.noreply.github.comBen Davis <45952064+bmdavis419@users.noreply.github.com> 2026-10-03 21:38:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 21:38:09-07:00
log845ddd9354538d42db1cc7dc30143c6bb8f0d40f
tree743789899195f84aa97f053c612efb0a381b1c8a
parent60a56f11e73dfc9c26e74f8f8e93e391191fc3f8
signature Signed by PGP key B5690EEEBB952194

refactor(clients): share opening a machine's No project folder (#14759)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

11 files changed, 220 insertions(+), 85 deletions(-)

apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-19
......@@ -2,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow";
22import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
33import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
44import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import { AsyncResult } from "effect/unstable/reactivity";
75import { LegendList } from "@legendapp/list/react-native";
86import {
97 isAtomCommandInterrupted,
......@@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym
3129import { ThemedSwitch } from "../../components/ThemedSwitch";
3230import { cn } from "../../lib/cn";
3331import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
34import { useServerConfigs, waitForProject } from "../../state/entities";
32import { useServerConfigs } from "../../state/entities";
3533import { projectEnvironment } from "../../state/projects";
3634import { useAtomCommand } from "../../state/use-atom-command";
3735import { vcsEnvironment } from "../../state/vcs";
......@@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {
208206 const navigation = useNavigation();
209207 const insets = useSafeAreaInsets();
210208 const serverConfigs = useServerConfigs();
211 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
209 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
212210 reportFailure: false,
213211 });
214212 const [movingToEnvironmentId, setMovingToEnvironmentId] = useState<EnvironmentId | null>(null);
......@@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() {
218216 async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {
219217 setMovingToEnvironmentId(environmentId);
220218 try {
221 const result = await ensureScratch({ environmentId, input: {} });
222 if (AsyncResult.isFailure(result)) {
223 const error = Cause.squash(result.cause);
224 Alert.alert(
225 "Could not switch machine",
226 error instanceof Error
227 ? error.message
228 : "The folder for threads without a project could not be created.",
229 );
230 return;
231 }
232 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
233 if (project === null) {
234 Alert.alert("Could not switch machine", "It has not reached this device yet. Try again.");
219 const result = await openScratch({ environmentId, input: {} });
220 if (result._tag === "Failure") {
221 if (!isAtomCommandInterrupted(result)) {
222 const error = squashAtomCommandFailure(result);
223 Alert.alert(
224 "Could not switch machine",
225 error instanceof Error
226 ? error.message
227 : "The folder for threads without a project could not be created.",
228 );
229 }
235230 return;
236231 }
237 flow.setProject(project);
232 flow.setProject(result.value);
238233 navigation.goBack();
239234 } finally {
240235 setMovingToEnvironmentId(null);
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+23-25
......@@ -7,11 +7,13 @@ import {
77 type StaticScreenProps,
88} from "@react-navigation/native";
99import { SymbolView } from "../../components/AppSymbol";
10import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
10import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
1111import { isScratchProject } from "@t3tools/client-runtime/state/projects";
12import {
13 isAtomCommandInterrupted,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
1216import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";
1517import { useEffect, useRef, useState } from "react";
1618import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
1719import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton";
2123import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2224import { AppText as Text } from "../../components/AppText";
2325import { ProjectFavicon } from "../../components/ProjectFavicon";
24import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
26import { useProjects, useServerConfigs } from "../../state/entities";
2527import { projectEnvironment } from "../../state/projects";
2628import { useAtomCommand } from "../../state/use-atom-command";
2729import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
......@@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
173175 ) ?? null)
174176 : null;
175177 const { connectedEnvironments } = useRemoteConnectionStatus();
176 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
178 const openScratch = useAtomCommand(projectEnvironment.openScratch, {
177179 reportFailure: false,
178180 });
179181 // Threads without a project need a connected environment that offers them.
......@@ -181,8 +183,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
181183 // first that does; the draft page's machine picker moves it from there.
182184 const scratchEnvironments = connectedEnvironments.filter(
183185 (environment) =>
184 canCreateProjectInEnvironment(environment.connectionState) &&
185 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
186 availableScratchWorkspaceRoot(
187 environment.connectionState,
188 serverConfigs.get(environment.environmentId),
189 ) !== null,
186190 );
187191 const scratchEnvironment =
188192 scratchEnvironments.find(
......@@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
230234 const environmentId = scratchEnvironment.environmentId;
231235 scratchStartInFlightRef.current = true;
232236 try {
233 const result = await ensureScratch({ environmentId, input: {} });
234 if (AsyncResult.isFailure(result)) {
235 const error = Cause.squash(result.cause);
236 Alert.alert(
237 "Could not start without a project",
238 error instanceof Error
239 ? error.message
240 : "The folder for threads without a project could not be created.",
241 );
242 return;
243 }
244 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
245 if (project === null) {
246 Alert.alert(
247 "Could not start without a project",
248 "It has not reached this device yet. Pick No project from the list once it appears.",
249 );
237 const result = await openScratch({ environmentId, input: {} });
238 if (result._tag === "Failure") {
239 if (!isAtomCommandInterrupted(result)) {
240 const error = squashAtomCommandFailure(result);
241 Alert.alert(
242 "Could not start without a project",
243 error instanceof Error
244 ? error.message
245 : "The folder for threads without a project could not be created.",
246 );
247 }
250248 return;
251249 }
252 await selectProject(project);
250 await selectProject(result.value);
253251 } finally {
254252 scratchStartInFlightRef.current = false;
255253 }
apps/mobile/src/features/threads/new-task-flow-provider.tsx+5-3
......@@ -89,7 +89,7 @@ import {
8989 useRemoteConnectionStatus,
9090 useSavedRemoteConnections,
9191} from "../../state/use-remote-environment-registry";
92import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
92import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
9393import { isScratchProject } from "@t3tools/client-runtime/state/projects";
9494import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
9595import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
......@@ -367,8 +367,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
367367 () =>
368368 connectedEnvironments.filter(
369369 (environment) =>
370 canCreateProjectInEnvironment(environment.connectionState) &&
371 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
370 availableScratchWorkspaceRoot(
371 environment.connectionState,
372 serverConfigs.get(environment.environmentId),
373 ) !== null,
372374 ),
373375 [connectedEnvironments, serverConfigs],
374376 );
apps/mobile/src/state/projects.ts+3-1
......@@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog";
55import { connectionAtomRuntime } from "../connection/runtime";
66import { environmentSnapshotAtom } from "./shell";
77
8export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
98export const environmentProjects = createEnvironmentProjectAtoms({
109 catalogValueAtom: environmentCatalog.catalogValueAtom,
1110 snapshotAtom: environmentSnapshotAtom,
1211});
12export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
13 projectAtom: environmentProjects.projectAtom,
14});
apps/web/src/components/ChatView.tsx+1-1
......@@ -4396,7 +4396,7 @@ export default function ChatView(props: ChatViewProps) {
43964396 }
43974397 // Keep send disabled until the destination Scratch project is ready.
43984398 setIsEnvironmentChanging(true);
4399 void openScratchProject(target.environmentId)
4399 void openScratchProject(target.environmentId, "Could not switch machine")
44004400 .then((project) => {
44014401 if (project) retarget(project);
44024402 })
apps/web/src/hooks/useScratchProject.ts+19-24
......@@ -1,4 +1,5 @@
11import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
23import {
34 isAtomCommandInterrupted,
45 squashAtomCommandFailure,
......@@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts";
89import { useCallback } from "react";
910
1011import { stackedThreadToast, toastManager } from "~/components/ui/toast";
11import { waitForProject } from "~/state/entities";
1212import { useEnvironments } from "~/state/environments";
1313import { projectEnvironment } from "~/state/projects";
1414import { useAtomCommand } from "~/state/use-atom-command";
1515import { useNewThreadHandler } from "./useHandleNewThread";
1616
17function reportScratchFailure(error: unknown) {
17function reportScratchFailure(title: string, error: unknown) {
1818 toastManager.add(
1919 stackedThreadToast({
2020 type: "error",
21 title: "Could not start without a project",
21 title,
2222 description: error instanceof Error ? error.message : "An error occurred.",
2323 }),
2424 );
......@@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) {
3232 */
3333export function useScratchProject() {
3434 const { environments } = useEnvironments();
35 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false });
35 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
3636 const handleNewThread = useNewThreadHandler();
3737
3838 /** The scratch folder of a connected environment, or null when it offers none. */
3939 const scratchWorkspaceRootFor = useCallback(
4040 (environmentId: EnvironmentId | null): string | null => {
4141 const environment = environments.find((entry) => entry.environmentId === environmentId);
42 return environment?.connection.phase === "connected"
43 ? (environment.serverConfig?.scratchWorkspaceRoot ?? null)
44 : null;
42 return availableScratchWorkspaceRoot(
43 environment?.connection.phase,
44 environment?.serverConfig,
45 );
4546 },
4647 [environments],
4748 );
......@@ -62,24 +63,18 @@ export function useScratchProject() {
6263
6364 /** Resolves to the scratch project once it is in this client's store. */
6465 const openScratchProject = useCallback(
65 async (environmentId: EnvironmentId): Promise<EnvironmentProject | null> => {
66 const result = await ensureScratch({ environmentId, input: {} });
67 if (result._tag === "Failure") {
68 if (!isAtomCommandInterrupted(result)) {
69 reportScratchFailure(squashAtomCommandFailure(result));
70 }
71 return null;
66 async (
67 environmentId: EnvironmentId,
68 failureTitle = "Could not start without a project",
69 ): Promise<EnvironmentProject | null> => {
70 const result = await openScratch({ environmentId, input: {} });
71 if (result._tag === "Success") return result.value;
72 if (!isAtomCommandInterrupted(result)) {
73 reportScratchFailure(failureTitle, squashAtomCommandFailure(result));
7274 }
73 // Drafts key off the project's stored path and settings, so wait for
74 // the create event to reach the store before targeting one.
75 return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch(
76 (error: unknown) => {
77 reportScratchFailure(error);
78 return null;
79 },
80 );
75 return null;
8176 },
82 [ensureScratch],
77 [openScratch],
8378 );
8479
8580 const startScratchThread = useCallback(
......@@ -87,7 +82,7 @@ export function useScratchProject() {
8782 const project = await openScratchProject(environmentId);
8883 if (project) {
8984 await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch(
90 reportScratchFailure,
85 (error: unknown) => reportScratchFailure("Could not start without a project", error),
9186 );
9287 }
9388 },
apps/web/src/state/projects.ts+7-5
......@@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog";
77import { connectionAtomRuntime } from "../connection/runtime";
88import { environmentSnapshotAtom } from "./shell";
99
10export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
10export const environmentProjects = createEnvironmentProjectAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 snapshotAtom: environmentSnapshotAtom,
13});
14export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, {
15 projectAtom: environmentProjects.projectAtom,
16});
1117/**
1218 * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile
1319 * surface, so the atom family lives here instead of the shared client-runtime
......@@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti
1925 staleTimeMs: 5_000,
2026 idleTtlMs: 60_000,
2127});
22export const environmentProjects = createEnvironmentProjectAtoms({
23 catalogValueAtom: environmentCatalog.catalogValueAtom,
24 snapshotAtom: environmentSnapshotAtom,
25});
docs/user/thread-sidebar.md+3-1
......@@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or
1919start without a project** under a new thread's heading, pick **No project** from
2020the project menu in that heading or from **New thread in...** in the command
2121palette, or press `mod+alt+n`. On mobile, pick **No project** from the project
22list. To move a draft into a project, pick the project in the heading.
22list. It starts on your current machine; before sending, pick another machine
23from the machine menu to move it there. To move a draft into a project, pick the
24project in the heading.
2325
2426Each thread without a project works in its own folder under `~/.t3/scratch` (the
2527`scratch` folder of your T3 data directory), named after its date, the first words
packages/client-runtime/src/operations/projects.ts+14
......@@ -4,6 +4,7 @@ import type {
44 EnvironmentId,
55 ProjectMutation,
66 ProjectId,
7 ServerConfig,
78 SourceControlDiscoveryResult,
89 SourceControlProviderKind,
910 SourceControlRepositoryInfo,
......@@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment(
3637 return connectionPhase === "connected";
3738}
3839
40/**
41 * The Scratch folder an environment offers threads without a project right
42 * now, or null while it is not connected or has none.
43 */
44export function availableScratchWorkspaceRoot(
45 connectionPhase: EnvironmentConnectionPhase | null | undefined,
46 serverConfig: Pick<ServerConfig, "scratchWorkspaceRoot"> | null | undefined,
47): string | null {
48 return canCreateProjectInEnvironment(connectionPhase)
49 ? (serverConfig?.scratchWorkspaceRoot ?? null)
50 : null;
51}
52
3953export type AddProjectRemoteSourceReadiness = Record<
4054 AddProjectRemoteSource,
4155 { readonly ready: boolean; readonly hint: string | null }
packages/client-runtime/src/state/projectCommands.test.ts created+77
......@@ -0,0 +1,77 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import { Atom, AtomRegistry } from "effect/unstable/reactivity";
10
11import * as EnvironmentRegistry from "../connection/registry.ts";
12import * as EnvironmentSupervisor from "../connection/supervisor.ts";
13import type { RpcSession } from "../rpc/session.ts";
14import type { EnvironmentProject } from "./models.ts";
15import { createProjectEnvironmentAtoms } from "./projectCommands.ts";
16
17const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
18const PROJECT_ID = ProjectId.make("scratch");
19const PROJECT = {
20 id: PROJECT_ID,
21 environmentId: ENVIRONMENT_ID,
22 workspaceRoot: "/scratch",
23} as EnvironmentProject;
24
25const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () {
26 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
27 target: { environmentId: ENVIRONMENT_ID },
28 session: yield* SubscriptionRef.make(
29 Option.some({
30 client: {
31 [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }),
32 },
33 } as unknown as RpcSession),
34 ),
35 } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
36 const runtime = Atom.runtime(
37 Layer.mergeAll(
38 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, {
39 run: (_environmentId, effect) =>
40 Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
41 } as EnvironmentRegistry.EnvironmentRegistry["Service"]),
42 Layer.succeed(
43 Crypto.Crypto,
44 Crypto.make({
45 randomBytes: (size) => new Uint8Array(size),
46 digest: (_algorithm, data) => Effect.succeed(data),
47 }),
48 ),
49 ),
50 );
51 const storedProject = Atom.make<EnvironmentProject | null>(null);
52 const commands = createProjectEnvironmentAtoms(runtime, {
53 projectAtom: (ref: ScopedProjectRef) =>
54 ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID
55 ? storedProject
56 : Atom.make(null),
57 });
58 const registry = AtomRegistry.make();
59 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
60 const openScratch = Effect.promise(() =>
61 commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }),
62 );
63 return { registry, storedProject, openScratch };
64});
65
66describe("openScratch", () => {
67 it.effect("resolves once the created project reaches the client store", () =>
68 Effect.gen(function* () {
69 const { registry, storedProject, openScratch } = yield* makeHarness();
70 const opening = yield* Effect.forkChild(openScratch);
71 yield* Effect.yieldNow;
72 registry.set(storedProject, PROJECT);
73 const result = yield* Fiber.join(opening);
74 expect(result).toMatchObject({ _tag: "Success", value: PROJECT });
75 }).pipe(Effect.scoped),
76 );
77});
packages/client-runtime/src/state/projectCommands.ts+54-6
......@@ -1,7 +1,20 @@
1import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts";
1import {
2 type EnvironmentId,
3 ProjectId,
4 type ProjectReadFileResult,
5 type ScopedProjectRef,
6 WS_METHODS,
7} from "@t3tools/contracts";
28import * as Crypto from "effect/Crypto";
3import { Atom } from "effect/unstable/reactivity";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Predicate from "effect/Predicate";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import { Atom, AtomRegistry } from "effect/unstable/reactivity";
415
16import { type EnvironmentRpcInput, request } from "../rpc/client.ts";
17import type { EnvironmentProject } from "./models.ts";
518import {
619 createAtomCommandScheduler,
720 createEnvironmentCommand,
......@@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string {
3952 return JSON.stringify([target.environmentId, target.cwd, target.relativePath]);
4053}
4154
55/** The Scratch project was created, but its event never reached this client. */
56export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()(
57 "ScratchProjectNotLoadedError",
58 { projectId: ProjectId },
59) {
60 override get message(): string {
61 return "The folder for threads without a project has not reached this device yet. Try again.";
62 }
63}
64
4265export function createProjectEnvironmentAtoms<R, E>(
4366 runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>,
67 options: {
68 /** The client store's project; openScratch waits here for the created project. */
69 readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>;
70 },
4471) {
4572 const projectScheduler = createAtomCommandScheduler();
4673 const fileScheduler = createAtomCommandScheduler();
......@@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms<R, E>(
92119 scheduler: projectScheduler,
93120 concurrency: projectConcurrency,
94121 }),
95 // Finds or creates the environment's Scratch project and returns its id.
96 ensureScratch: createEnvironmentRpcCommand(runtime, {
97 label: "environment-data:projects:ensure-scratch",
98 tag: WS_METHODS.projectsEnsureScratch,
122 // Finds or creates the environment's Scratch project and resolves once the
123 // project is in the client store, since drafts key off its stored path.
124 openScratch: createEnvironmentCommand(runtime, {
125 label: "environment-data:projects:open-scratch",
126 execute: (
127 input: EnvironmentRpcInput<typeof WS_METHODS.projectsEnsureScratch>,
128 registry,
129 environmentId,
130 ) =>
131 request(WS_METHODS.projectsEnsureScratch, input).pipe(
132 Effect.flatMap(({ projectId }) =>
133 AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe(
134 Stream.filter(Predicate.isNotNull),
135 Stream.runHead,
136 Effect.timeoutOption("10 seconds"),
137 Effect.map(Option.flatten),
138 Effect.flatMap(
139 Option.match({
140 onSome: Effect.succeed,
141 onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })),
142 }),
143 ),
144 ),
145 ),
146 ),
99147 scheduler: projectScheduler,
100148 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
101149 }),