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";
4949import { AsyncResult } from "effect/unstable/reactivity";
5050import { cn } from "../../lib/cn";
5151
52import { useProjects, useServerConfigs } from "../../state/entities";
52import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
5353import { filesystemEnvironment } from "../../state/filesystem";
5454import { projectEnvironment } from "../../state/projects";
5555import { useEnvironmentQuery } from "../../state/query";
......@@ -77,6 +77,8 @@ interface EnvironmentOption {
7777 readonly connectionState: EnvironmentConnectionPhase;
7878 readonly connectionError: string | null;
7979 readonly connectionErrorTraceId: string | null;
80 /** Server runs clones in the background and streams progress; older servers block. */
81 readonly supportsCloneTracking: boolean;
8082}
8183
8284const environmentOptionOrder = Order.mapInput(
......@@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
366368 connectionState: runtime?.connectionState ?? "available",
367369 connectionError: runtime?.connectionError ?? null,
368370 connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
371 supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true,
369372 };
370373 });
371374 return Arr.sort(options, environmentOptionOrder);
......@@ -575,6 +578,15 @@ export function AddProjectSourceScreen() {
575578 );
576579}
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
578590function useCreateProject(environment: EnvironmentOption | null) {
579591 const navigation = useNavigation();
580592 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
......@@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: {
908920 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
909921 reportFailure: false,
910922 });
923 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
924 reportFailure: false,
925 });
926 const navigation = useNavigation();
911927 const environment = useEnvironmentFromParam(props.environmentId);
912928 const createProject = useCreateProject(environment);
913929 const remoteUrl = stringParam(props.remoteUrl);
......@@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: {
938954 }
939955
940956 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 }
941999 const cloneResult = await cloneRepository({
9421000 environmentId: environment.environmentId,
9431001 input: {
......@@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: {
9601018 environment,
9611019 isBrowseNavigating,
9621020 isSubmitting,
1021 navigation,
9631022 pathInput,
9641023 remoteUrl,
1024 startProjectClone,
9651025 ]);
9661026
9671027 return (
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+3
......@@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = {
2121 readonly branch?: string | null;
2222 readonly worktreePath?: string | null;
2323 readonly title?: string | string[];
24 /** Set by Add Project when this draft opens while the project's clone runs. */
25 readonly cloning?: string | string[];
2426 readonly pendingTaskId?: string | string[];
2527 readonly draftId?: string | string[];
2628 readonly incomingShareId?: string | string[];
......@@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf
4850 projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId,
4951 branch: params.branch,
5052 worktreePath: params.worktreePath,
53 cloning: (Array.isArray(params.cloning) ? params.cloning[0] : params.cloning) === "1",
5154 }),
5255 [params],
5356 );
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+113-9
......@@ -1,4 +1,6 @@
11import { useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";
24import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
35import {
46 nextPastedTextFileName,
......@@ -103,6 +105,11 @@ import {
103105 waitForComposerDraftsLoaded,
104106} from "../../state/use-composer-drafts";
105107import { 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";
106113import {
107114 isModelSelectionUnavailable,
108115 resolveSelectableModelSelection,
......@@ -165,6 +172,8 @@ export function NewTaskDraftScreen(props: {
165172 readonly projectId?: string;
166173 readonly branch?: string | null;
167174 readonly worktreePath?: string | null;
175 /** The project was just added by a clone that is still running. */
176 readonly cloning?: boolean;
168177 };
169178 /** Queued outbox message id when editing an existing pending task. */
170179 readonly pendingTaskId?: string;
......@@ -198,6 +207,70 @@ export function NewTaskDraftScreen(props: {
198207 (environment) => environment.environmentId === selectedProject.environmentId,
199208 )?.connectionState === "connected";
200209 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 };
201274 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
202275 const attachmentBlockReason = selectedProject
203276 ? composerAttachmentUploadBlockReason({
......@@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: {
12611334 const isAndroid = Platform.OS === "android";
12621335 const canStart =
12631336 !isImportingContext &&
1337 !cloneBlocksStart &&
12641338 attachmentBlockReason === null &&
12651339 !modelUnavailable &&
12661340 Boolean(flow.selectedProject) &&
......@@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: {
14771551 />
14781552 </View>
14791553 ) : 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}
14801580 <View className="pb-1">{workspaceControls}</View>
14811581
14821582 {modelUnavailable ? (
......@@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: {
16181718 <ComposerActionButton
16191719 accessibilityLabel={
16201720 attachmentBlockReason ??
1621 (pendingPastedTextAttachmentCount > 0
1622 ? "Attaching pasted text"
1623 : flow.submitting
1624 ? "Starting task"
1625 : attachmentsUploading
1626 ? "Queue task, sends when uploads finish"
1627 : environmentConnected
1628 ? "Start task"
1629 : "Queue task")
1721 (cloneBlocksStart
1722 ? projectClone === null || projectClone.phase === "running"
1723 ? "Cloning repository"
1724 : "Repository not cloned"
1725 : pendingPastedTextAttachmentCount > 0
1726 ? "Attaching pasted text"
1727 : flow.submitting
1728 ? "Starting task"
1729 : attachmentsUploading
1730 ? "Queue task, sends when uploads finish"
1731 : environmentConnected
1732 ? "Start task"
1733 : "Queue task")
16301734 }
16311735 disabled={!canStart}
16321736 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
apps/mobile/src/state/entities.ts+27
......@@ -1,4 +1,6 @@
11import { useAtomValue } from "@effect/atom-react";
2
3import { appAtomRegistry } from "./atom-registry";
24import type {
35 EnvironmentProject,
46 EnvironmentThreadShell,
......@@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
2527 Atom.withLabel("mobile-server-config:empty"),
2628);
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
2855export function useProjects(): ReadonlyArray<EnvironmentProject> {
2956 return useAtomValue(environmentProjects.projectsAtom);
3057}
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}