authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 13:00:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 13:00:29-07:00
log549d182aaadbf0e1e195d1a8cdc1805200e6751e
treec282279557c316036724112d3441afeba2f38fe9
parent8d7c700c1788c4f42932cd3be541b3c630bd664d
signature Signed by PGP key B5690EEEBB952194

feat(mobile): clone repositories in the background and gate the draft on the clone (#11774)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 340 insertions(+), 10 deletions(-)

apps/mobile/src/components/ProjectCloneBanner.tsx created+81
...@@ -0,0 +1,81 @@
1import {
2 projectCloneDisplayName,
3 projectCloneProgressSummary,
4 type ProjectCloneSnapshot,
5} from "@t3tools/contracts";
6import { ActivityIndicator, Pressable, View } from "react-native";
7
8import { cn } from "../lib/cn";
9import { AppText as Text } from "./AppText";
10
11/**
12 * Live state of the clone that backs a freshly added project, shown above
13 * the composer while the draft waits for its files. Running clones offer
14 * Cancel; failed or cancelled ones offer Retry and Remove project.
15 */
16export function ProjectCloneBanner(props: {
17 readonly clone: ProjectCloneSnapshot;
18 readonly onCancel: () => void;
19 readonly onRetry: () => void;
20 readonly onRemove: () => void;
21}) {
22 const { clone } = props;
23 const name = projectCloneDisplayName(clone);
24 if (clone.phase === "running") {
25 return (
26 <View className="flex-row items-center gap-3 rounded-2xl border border-border bg-card px-3.5 py-3">
27 <ActivityIndicator size="small" />
28 <View className="min-w-0 flex-1">
29 <Text className="font-t3-medium text-sm" numberOfLines={1}>
30 Cloning {name}
31 </Text>
32 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
33 {projectCloneProgressSummary(clone)}
34 </Text>
35 </View>
36 <BannerAction label="Cancel" onPress={props.onCancel} />
37 </View>
38 );
39 }
40 const cancelled = clone.phase === "cancelled";
41 return (
42 <View
43 className={cn(
44 "rounded-2xl border px-3.5 py-3",
45 cancelled ? "border-warning-border bg-warning" : "border-danger-border bg-danger",
46 )}
47 >
48 <Text
49 className={cn(
50 "font-t3-medium text-sm",
51 cancelled ? "text-warning-foreground" : "text-danger-foreground",
52 )}
53 numberOfLines={1}
54 >
55 {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`}
56 </Text>
57 {clone.error ? (
58 <Text className="mt-0.5 text-xs text-danger-foreground" numberOfLines={3}>
59 {clone.error}
60 </Text>
61 ) : null}
62 <View className="mt-2 flex-row justify-end gap-2">
63 <BannerAction label="Remove project" onPress={props.onRemove} />
64 <BannerAction label="Retry" onPress={props.onRetry} />
65 </View>
66 </View>
67 );
68}
69
70function BannerAction(props: { readonly label: string; readonly onPress: () => void }) {
71 return (
72 <Pressable
73 accessibilityRole="button"
74 accessibilityLabel={props.label}
75 className="rounded-full border border-border bg-background px-3 py-1.5"
76 onPress={props.onPress}
77 >
78 <Text className="font-t3-medium text-xs">{props.label}</Text>
79 </Pressable>
80 );
81}
apps/mobile/src/features/projects/AddProjectScreen.tsx+61-1
...@@ -49,7 +49,7 @@ import * as Order from "effect/Order";...@@ -49,7 +49,7 @@ import * as Order from "effect/Order";
49import { AsyncResult } from "effect/unstable/reactivity";49import { AsyncResult } from "effect/unstable/reactivity";
50import { cn } from "../../lib/cn";50import { cn } from "../../lib/cn";
5151
52import { useProjects, useServerConfigs } from "../../state/entities";52import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
53import { filesystemEnvironment } from "../../state/filesystem";53import { filesystemEnvironment } from "../../state/filesystem";
54import { projectEnvironment } from "../../state/projects";54import { projectEnvironment } from "../../state/projects";
55import { useEnvironmentQuery } from "../../state/query";55import { useEnvironmentQuery } from "../../state/query";
...@@ -77,6 +77,8 @@ interface EnvironmentOption {...@@ -77,6 +77,8 @@ interface EnvironmentOption {
77 readonly connectionState: EnvironmentConnectionPhase;77 readonly connectionState: EnvironmentConnectionPhase;
78 readonly connectionError: string | null;78 readonly connectionError: string | null;
79 readonly connectionErrorTraceId: string | null;79 readonly connectionErrorTraceId: string | null;
80 /** Server runs clones in the background and streams progress; older servers block. */
81 readonly supportsCloneTracking: boolean;
80}82}
8183
82const environmentOptionOrder = Order.mapInput(84const environmentOptionOrder = Order.mapInput(
...@@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {...@@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
366 connectionState: runtime?.connectionState ?? "available",368 connectionState: runtime?.connectionState ?? "available",
367 connectionError: runtime?.connectionError ?? null,369 connectionError: runtime?.connectionError ?? null,
368 connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,370 connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
371 supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true,
369 };372 };
370 });373 });
371 return Arr.sort(options, environmentOptionOrder);374 return Arr.sort(options, environmentOptionOrder);
...@@ -575,6 +578,15 @@ export function AddProjectSourceScreen() {...@@ -575,6 +578,15 @@ export function AddProjectSourceScreen() {
575 );578 );
576}579}
577580
581function openNewTaskDraft(
582 navigation: { dispatch: (action: ReturnType<typeof CommonActions.reset>) => void },
583 params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" },
584) {
585 navigation.dispatch(
586 CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),
587 );
588}
589
578function useCreateProject(environment: EnvironmentOption | null) {590function useCreateProject(environment: EnvironmentOption | null) {
579 const navigation = useNavigation();591 const navigation = useNavigation();
580 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });592 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
...@@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: {...@@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: {
908 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {920 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
909 reportFailure: false,921 reportFailure: false,
910 });922 });
923 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
924 reportFailure: false,
925 });
926 const navigation = useNavigation();
911 const environment = useEnvironmentFromParam(props.environmentId);927 const environment = useEnvironmentFromParam(props.environmentId);
912 const createProject = useCreateProject(environment);928 const createProject = useCreateProject(environment);
913 const remoteUrl = stringParam(props.remoteUrl);929 const remoteUrl = stringParam(props.remoteUrl);
...@@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: {...@@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: {
938 }954 }
939955
940 setIsSubmitting(true);956 setIsSubmitting(true);
957 if (environment.supportsCloneTracking) {
958 // The server creates the project and clones in the background; the
959 // draft screen shows progress and holds Start until the files land.
960 const projectId = ProjectId.make(uuidv4());
961 const title = inferProjectTitleFromPath(resolved.path);
962 const startResult = await startProjectClone({
963 environmentId: environment.environmentId,
964 input: {
965 projectId,
966 title,
967 createdAt: new Date().toISOString(),
968 remoteUrl,
969 destinationPath: resolved.path,
970 },
971 });
972 if (AsyncResult.isFailure(startResult)) {
973 setError(errorMessage(Cause.squash(startResult.cause)));
974 } else {
975 // The draft screen resolves its project from the client store, so it
976 // must not open before the create event has arrived (it would fall
977 // back to the project picker and lose the clone controls). Stay in
978 // the submitting state until then; the clone keeps running either way.
979 const project = await waitForProject(
980 { environmentId: environment.environmentId, projectId },
981 15_000,
982 );
983 if (project === null) {
984 setError(
985 "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
986 );
987 } else {
988 openNewTaskDraft(navigation, {
989 environmentId: environment.environmentId,
990 projectId,
991 title,
992 cloning: "1",
993 });
994 }
995 }
996 setIsSubmitting(false);
997 return;
998 }
941 const cloneResult = await cloneRepository({999 const cloneResult = await cloneRepository({
942 environmentId: environment.environmentId,1000 environmentId: environment.environmentId,
943 input: {1001 input: {
...@@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: {...@@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: {
960 environment,1018 environment,
961 isBrowseNavigating,1019 isBrowseNavigating,
962 isSubmitting,1020 isSubmitting,
1021 navigation,
963 pathInput,1022 pathInput,
964 remoteUrl,1023 remoteUrl,
1024 startProjectClone,
965 ]);1025 ]);
9661026
967 return (1027 return (
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+3
...@@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = {...@@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = {
21 readonly branch?: string | null;21 readonly branch?: string | null;
22 readonly worktreePath?: string | null;22 readonly worktreePath?: string | null;
23 readonly title?: string | string[];23 readonly title?: string | string[];
24 /** Set by Add Project when this draft opens while the project's clone runs. */
25 readonly cloning?: string | string[];
24 readonly pendingTaskId?: string | string[];26 readonly pendingTaskId?: string | string[];
25 readonly draftId?: string | string[];27 readonly draftId?: string | string[];
26 readonly incomingShareId?: string | string[];28 readonly incomingShareId?: string | string[];
...@@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf...@@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
48 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,50 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
49 branch: params.branch,51 branch: params.branch,
50 worktreePath: params.worktreePath,52 worktreePath: params.worktreePath,
53 cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1",
51 }),54 }),
52 [params],55 [params],
53 );56 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+113-9
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";4import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3import {5import {
4 nextPastedTextFileName,6 nextPastedTextFileName,
...@@ -103,6 +105,11 @@ import {...@@ -103,6 +105,11 @@ import {
103 waitForComposerDraftsLoaded,105 waitForComposerDraftsLoaded,
104} from "../../state/use-composer-drafts";106} from "../../state/use-composer-drafts";
105import { useEnvironmentServerConfig, useProjects } from "../../state/entities";107import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
108import { useProjectClone } from "../../state/projectClones";
109import { projectEnvironment } from "../../state/projects";
110import { sourceControlEnvironment } from "../../state/sourceControl";
111import { useAtomCommand } from "../../state/use-atom-command";
112import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";
106import {113import {
107 isModelSelectionUnavailable,114 isModelSelectionUnavailable,
108 resolveSelectableModelSelection,115 resolveSelectableModelSelection,
...@@ -165,6 +172,8 @@ export function NewTaskDraftScreen(props: {...@@ -165,6 +172,8 @@ export function NewTaskDraftScreen(props: {
165 readonly projectId?: string;172 readonly projectId?: string;
166 readonly branch?: string | null;173 readonly branch?: string | null;
167 readonly worktreePath?: string | null;174 readonly worktreePath?: string | null;
175 /** The project was just added by a clone that is still running. */
176 readonly cloning?: boolean;
168 };177 };
169 /** Queued outbox message id when editing an existing pending task. */178 /** Queued outbox message id when editing an existing pending task. */
170 readonly pendingTaskId?: string;179 readonly pendingTaskId?: string;
...@@ -198,6 +207,70 @@ export function NewTaskDraftScreen(props: {...@@ -198,6 +207,70 @@ export function NewTaskDraftScreen(props: {
198 (environment) => environment.environmentId === selectedProject.environmentId,207 (environment) => environment.environmentId === selectedProject.environmentId,
199 )?.connectionState === "connected";208 )?.connectionState === "connected";
200 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;209 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
210 // A project added by cloning exists before its files do: the prompt can be
211 // written meanwhile, but Start waits for the clone.
212 const projectCloneState = useProjectClone(
213 selectedProject
214 ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id }
215 : null,
216 );
217 const projectClone = projectCloneState === "pending" ? null : projectCloneState;
218 // Before the clone stream delivers, only the project this draft was opened
219 // for by Add Project is known to be cloning; any other project (offline
220 // included) must still be able to queue a task.
221 const awaitingKnownClone =
222 projectCloneState === "pending" &&
223 props.initialProjectRef?.cloning === true &&
224 selectedProject !== null &&
225 selectedProject.id === props.initialProjectRef.projectId &&
226 selectedProject.environmentId === props.initialProjectRef.environmentId;
227 const cloneBlocksStart =
228 awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done");
229 const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
230 reportFailure: false,
231 });
232 const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
233 reportFailure: false,
234 });
235 // The banner only reflects the server's state, so a request that never got
236 // there needs its own feedback.
237 const runCloneAction = async (
238 title: string,
239 action: () => Promise<AsyncResult.AsyncResult<unknown, unknown>>,
240 ) => {
241 const result = await action();
242 if (AsyncResult.isFailure(result)) {
243 const error = Cause.squash(result.cause);
244 Alert.alert(title, error instanceof Error ? error.message : "An error occurred.");
245 }
246 };
247 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
248 // The delete is awaited; by then the picker may point somewhere else, and
249 // only the removed project's draft should leave the screen.
250 const selectedProjectRef = useRef(selectedProject);
251 useEffect(() => {
252 selectedProjectRef.current = selectedProject;
253 }, [selectedProject]);
254 const removeClonedProject = async () => {
255 if (!selectedProject) return;
256 const removed = selectedProject;
257 const result = await deleteProject({
258 environmentId: removed.environmentId,
259 input: { projectId: removed.id },
260 });
261 if (AsyncResult.isFailure(result)) {
262 const error = Cause.squash(result.cause);
263 Alert.alert(
264 "Failed to remove project",
265 error instanceof Error ? error.message : "An error occurred.",
266 );
267 return;
268 }
269 const current = selectedProjectRef.current;
270 if (current?.id === removed.id && current.environmentId === removed.environmentId) {
271 navigation.dispatch(StackActions.replace("Home"));
272 }
273 };
201 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);274 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
202 const attachmentBlockReason = selectedProject275 const attachmentBlockReason = selectedProject
203 ? composerAttachmentUploadBlockReason({276 ? composerAttachmentUploadBlockReason({
...@@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: {...@@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: {
1261 const isAndroid = Platform.OS === "android";1334 const isAndroid = Platform.OS === "android";
1262 const canStart =1335 const canStart =
1263 !isImportingContext &&1336 !isImportingContext &&
1337 !cloneBlocksStart &&
1264 attachmentBlockReason === null &&1338 attachmentBlockReason === null &&
1265 !modelUnavailable &&1339 !modelUnavailable &&
1266 Boolean(flow.selectedProject) &&1340 Boolean(flow.selectedProject) &&
...@@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: {...@@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: {
1477 />1551 />
1478 </View>1552 </View>
1479 ) : null}1553 ) : null}
1554 {/* Above the workspace controls so they keep their place relative to
1555 the composer when the banner goes away once the clone lands. */}
1556 {projectClone && projectClone.phase !== "done" && selectedProject ? (
1557 <View className="px-1 pb-2">
1558 <ProjectCloneBanner
1559 clone={projectClone}
1560 onCancel={() =>
1561 void runCloneAction("Failed to cancel clone", () =>
1562 cancelProjectClone({
1563 environmentId: selectedProject.environmentId,
1564 input: { projectId: selectedProject.id },
1565 }),
1566 )
1567 }
1568 onRetry={() =>
1569 void runCloneAction("Failed to retry clone", () =>
1570 retryProjectClone({
1571 environmentId: selectedProject.environmentId,
1572 input: { projectId: selectedProject.id },
1573 }),
1574 )
1575 }
1576 onRemove={() => void removeClonedProject()}
1577 />
1578 </View>
1579 ) : null}
1480 <View className="pb-1">{workspaceControls}</View>1580 <View className="pb-1">{workspaceControls}</View>
14811581
1482 {modelUnavailable ? (1582 {modelUnavailable ? (
...@@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: {...@@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: {
1618 <ComposerActionButton1718 <ComposerActionButton
1619 accessibilityLabel={1719 accessibilityLabel={
1620 attachmentBlockReason ??1720 attachmentBlockReason ??
1621 (pendingPastedTextAttachmentCount > 01721 (cloneBlocksStart
1622 ? "Attaching pasted text"1722 ? projectClone === null || projectClone.phase === "running"
1623 : flow.submitting1723 ? "Cloning repository"
1624 ? "Starting task"1724 : "Repository not cloned"
1625 : attachmentsUploading1725 : pendingPastedTextAttachmentCount > 0
1626 ? "Queue task, sends when uploads finish"1726 ? "Attaching pasted text"
1627 : environmentConnected1727 : flow.submitting
1628 ? "Start task"1728 ? "Starting task"
1629 : "Queue task")1729 : attachmentsUploading
1730 ? "Queue task, sends when uploads finish"
1731 : environmentConnected
1732 ? "Start task"
1733 : "Queue task")
1630 }1734 }
1631 disabled={!canStart}1735 disabled={!canStart}
1632 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}1736 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
apps/mobile/src/state/entities.ts+27
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2
3import { appAtomRegistry } from "./atom-registry";
2import type {4import type {
3 EnvironmentProject,5 EnvironmentProject,
4 EnvironmentThreadShell,6 EnvironmentThreadShell,
...@@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(...@@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
25 Atom.withLabel("mobile-server-config:empty"),27 Atom.withLabel("mobile-server-config:empty"),
26);28);
2729
30/** Resolves when the project event reaches the live client store. */
31export function waitForProject(
32 ref: ScopedProjectRef,
33 timeoutMs = 10_000,
34): Promise<EnvironmentProject | null> {
35 const atom = environmentProjects.projectAtom(ref);
36 const current = appAtomRegistry.get(atom);
37 if (current !== null) return Promise.resolve(current);
38 return new Promise((resolve) => {
39 let unsubscribe: (() => void) | null = null;
40 const timeout = setTimeout(() => {
41 unsubscribe?.();
42 resolve(null);
43 }, timeoutMs);
44 const finish = (project: EnvironmentProject | null) => {
45 if (project === null) return;
46 clearTimeout(timeout);
47 unsubscribe?.();
48 resolve(project);
49 };
50 unsubscribe = appAtomRegistry.subscribe(atom, finish);
51 finish(appAtomRegistry.get(atom));
52 });
53}
54
28export function useProjects(): ReadonlyArray<EnvironmentProject> {55export function useProjects(): ReadonlyArray<EnvironmentProject> {
29 return useAtomValue(environmentProjects.projectsAtom);56 return useAtomValue(environmentProjects.projectsAtom);
30}57}
apps/mobile/src/state/projectClones.ts created+55
...@@ -0,0 +1,55 @@
1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6
7import { serverEnvironment } from "./server";
8import { sourceControlEnvironment } from "./sourceControl";
9
10/**
11 * `"pending"` while the environment's clone stream has not delivered its
12 * first list yet: the draft opens right after a clone starts, and a caller
13 * that treated the gap as "no clone" would enable Start for a moment.
14 */
15export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null;
16
17const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = [];
18const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneState>(null).pipe(
19 Atom.withLabel("mobile-project-clone:empty"),
20);
21
22/**
23 * Latest clone list an environment has streamed, `"pending"` until the first
24 * one, and never subscribed on servers that predate clone tracking.
25 */
26const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
27 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> | "pending" => {
28 const config = get(serverEnvironment.configValueAtom(environmentId));
29 if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES;
30 const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} }));
31 // A failed subscription must not hold Start forever. Treating it as
32 // "no clone tracked" lets the server's own dispatch guard decide; the
33 // registry re-establishes the stream on reconnect.
34 if (result._tag === "Failure") return EMPTY_CLONES;
35 return Option.getOrElse(AsyncResult.value(result), () => "pending" as const);
36 }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
37);
38
39const projectCloneAtom = Atom.family((key: string) => {
40 const ref = parseScopedProjectKey(key);
41 return Atom.make((get): ProjectCloneState => {
42 if (ref === null) return null;
43 const clones = get(environmentProjectClonesAtom(ref.environmentId));
44 if (clones === "pending") return "pending";
45 return clones.find((clone) => clone.projectId === ref.projectId) ?? null;
46 }).pipe(Atom.withLabel(`mobile-project-clone:${key}`));
47});
48
49/**
50 * The tracked clone for a project: `"pending"` before the stream's first
51 * list, null once it finished or never started.
52 */
53export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState {
54 return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref)));
55}