diff --git a/apps/mobile/src/components/ProjectCloneBanner.tsx b/apps/mobile/src/components/ProjectCloneBanner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cca6a71e3f5e82f713a5095ceeb61b368aef32c0
--- /dev/null
+++ b/apps/mobile/src/components/ProjectCloneBanner.tsx
@@ -0,0 +1,81 @@
+import {
+ projectCloneDisplayName,
+ projectCloneProgressSummary,
+ type ProjectCloneSnapshot,
+} from "@t3tools/contracts";
+import { ActivityIndicator, Pressable, View } from "react-native";
+
+import { cn } from "../lib/cn";
+import { AppText as Text } from "./AppText";
+
+/**
+ * Live state of the clone that backs a freshly added project, shown above
+ * the composer while the draft waits for its files. Running clones offer
+ * Cancel; failed or cancelled ones offer Retry and Remove project.
+ */
+export function ProjectCloneBanner(props: {
+ readonly clone: ProjectCloneSnapshot;
+ readonly onCancel: () => void;
+ readonly onRetry: () => void;
+ readonly onRemove: () => void;
+}) {
+ const { clone } = props;
+ const name = projectCloneDisplayName(clone);
+ if (clone.phase === "running") {
+ return (
+
+
+
+
+ Cloning {name}
+
+
+ {projectCloneProgressSummary(clone)}
+
+
+
+
+ );
+ }
+ const cancelled = clone.phase === "cancelled";
+ return (
+
+
+ {cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`}
+
+ {clone.error ? (
+
+ {clone.error}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+function BannerAction(props: { readonly label: string; readonly onPress: () => void }) {
+ return (
+
+ {props.label}
+
+ );
+}
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index 5231228829d32819a4eb24e9ea4436d0165635df..5724abb138c8579dd4d051a0b2a36581564ff19a 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -49,7 +49,7 @@ import * as Order from "effect/Order";
import { AsyncResult } from "effect/unstable/reactivity";
import { cn } from "../../lib/cn";
-import { useProjects, useServerConfigs } from "../../state/entities";
+import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
@@ -77,6 +77,8 @@ interface EnvironmentOption {
readonly connectionState: EnvironmentConnectionPhase;
readonly connectionError: string | null;
readonly connectionErrorTraceId: string | null;
+ /** Server runs clones in the background and streams progress; older servers block. */
+ readonly supportsCloneTracking: boolean;
}
const environmentOptionOrder = Order.mapInput(
@@ -366,6 +368,7 @@ function useEnvironmentOptions(): ReadonlyArray {
connectionState: runtime?.connectionState ?? "available",
connectionError: runtime?.connectionError ?? null,
connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null,
+ supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true,
};
});
return Arr.sort(options, environmentOptionOrder);
@@ -575,6 +578,15 @@ export function AddProjectSourceScreen() {
);
}
+function openNewTaskDraft(
+ navigation: { dispatch: (action: ReturnType) => void },
+ params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" },
+) {
+ navigation.dispatch(
+ CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),
+ );
+}
+
function useCreateProject(environment: EnvironmentOption | null) {
const navigation = useNavigation();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
@@ -908,6 +920,10 @@ export function AddProjectDestinationScreen(props: {
const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
reportFailure: false,
});
+ const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
+ reportFailure: false,
+ });
+ const navigation = useNavigation();
const environment = useEnvironmentFromParam(props.environmentId);
const createProject = useCreateProject(environment);
const remoteUrl = stringParam(props.remoteUrl);
@@ -938,6 +954,48 @@ export function AddProjectDestinationScreen(props: {
}
setIsSubmitting(true);
+ if (environment.supportsCloneTracking) {
+ // The server creates the project and clones in the background; the
+ // draft screen shows progress and holds Start until the files land.
+ const projectId = ProjectId.make(uuidv4());
+ const title = inferProjectTitleFromPath(resolved.path);
+ const startResult = await startProjectClone({
+ environmentId: environment.environmentId,
+ input: {
+ projectId,
+ title,
+ createdAt: new Date().toISOString(),
+ remoteUrl,
+ destinationPath: resolved.path,
+ },
+ });
+ if (AsyncResult.isFailure(startResult)) {
+ setError(errorMessage(Cause.squash(startResult.cause)));
+ } else {
+ // The draft screen resolves its project from the client store, so it
+ // must not open before the create event has arrived (it would fall
+ // back to the project picker and lose the clone controls). Stay in
+ // the submitting state until then; the clone keeps running either way.
+ const project = await waitForProject(
+ { environmentId: environment.environmentId, projectId },
+ 15_000,
+ );
+ if (project === null) {
+ setError(
+ "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
+ );
+ } else {
+ openNewTaskDraft(navigation, {
+ environmentId: environment.environmentId,
+ projectId,
+ title,
+ cloning: "1",
+ });
+ }
+ }
+ setIsSubmitting(false);
+ return;
+ }
const cloneResult = await cloneRepository({
environmentId: environment.environmentId,
input: {
@@ -960,8 +1018,10 @@ export function AddProjectDestinationScreen(props: {
environment,
isBrowseNavigating,
isSubmitting,
+ navigation,
pathInput,
remoteUrl,
+ startProjectClone,
]);
return (
diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
index aab423a3792e7b40b011297ca42cd798cc80ad4e..67b68b35f991d82ea24d035cad4544077dd16cae 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx
@@ -21,6 +21,8 @@ type NewTaskDraftRouteParams = {
readonly branch?: string | null;
readonly worktreePath?: string | null;
readonly title?: string | string[];
+ /** Set by Add Project when this draft opens while the project's clone runs. */
+ readonly cloning?: string | string[];
readonly pendingTaskId?: string | string[];
readonly draftId?: string | string[];
readonly incomingShareId?: string | string[];
@@ -48,6 +50,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps environment.environmentId === selectedProject.environmentId,
)?.connectionState === "connected";
const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
+ // A project added by cloning exists before its files do: the prompt can be
+ // written meanwhile, but Start waits for the clone.
+ const projectCloneState = useProjectClone(
+ selectedProject
+ ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id }
+ : null,
+ );
+ const projectClone = projectCloneState === "pending" ? null : projectCloneState;
+ // Before the clone stream delivers, only the project this draft was opened
+ // for by Add Project is known to be cloning; any other project (offline
+ // included) must still be able to queue a task.
+ const awaitingKnownClone =
+ projectCloneState === "pending" &&
+ props.initialProjectRef?.cloning === true &&
+ selectedProject !== null &&
+ selectedProject.id === props.initialProjectRef.projectId &&
+ selectedProject.environmentId === props.initialProjectRef.environmentId;
+ const cloneBlocksStart =
+ awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done");
+ const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
+ reportFailure: false,
+ });
+ const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
+ reportFailure: false,
+ });
+ // The banner only reflects the server's state, so a request that never got
+ // there needs its own feedback.
+ const runCloneAction = async (
+ title: string,
+ action: () => Promise>,
+ ) => {
+ const result = await action();
+ if (AsyncResult.isFailure(result)) {
+ const error = Cause.squash(result.cause);
+ Alert.alert(title, error instanceof Error ? error.message : "An error occurred.");
+ }
+ };
+ const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
+ // The delete is awaited; by then the picker may point somewhere else, and
+ // only the removed project's draft should leave the screen.
+ const selectedProjectRef = useRef(selectedProject);
+ useEffect(() => {
+ selectedProjectRef.current = selectedProject;
+ }, [selectedProject]);
+ const removeClonedProject = async () => {
+ if (!selectedProject) return;
+ const removed = selectedProject;
+ const result = await deleteProject({
+ environmentId: removed.environmentId,
+ input: { projectId: removed.id },
+ });
+ if (AsyncResult.isFailure(result)) {
+ const error = Cause.squash(result.cause);
+ Alert.alert(
+ "Failed to remove project",
+ error instanceof Error ? error.message : "An error occurred.",
+ );
+ return;
+ }
+ const current = selectedProjectRef.current;
+ if (current?.id === removed.id && current.environmentId === removed.environmentId) {
+ navigation.dispatch(StackActions.replace("Home"));
+ }
+ };
const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
const attachmentBlockReason = selectedProject
? composerAttachmentUploadBlockReason({
@@ -1261,6 +1334,7 @@ export function NewTaskDraftScreen(props: {
const isAndroid = Platform.OS === "android";
const canStart =
!isImportingContext &&
+ !cloneBlocksStart &&
attachmentBlockReason === null &&
!modelUnavailable &&
Boolean(flow.selectedProject) &&
@@ -1477,6 +1551,32 @@ export function NewTaskDraftScreen(props: {
/>
) : null}
+ {/* Above the workspace controls so they keep their place relative to
+ the composer when the banner goes away once the clone lands. */}
+ {projectClone && projectClone.phase !== "done" && selectedProject ? (
+
+
+ void runCloneAction("Failed to cancel clone", () =>
+ cancelProjectClone({
+ environmentId: selectedProject.environmentId,
+ input: { projectId: selectedProject.id },
+ }),
+ )
+ }
+ onRetry={() =>
+ void runCloneAction("Failed to retry clone", () =>
+ retryProjectClone({
+ environmentId: selectedProject.environmentId,
+ input: { projectId: selectedProject.id },
+ }),
+ )
+ }
+ onRemove={() => void removeClonedProject()}
+ />
+
+ ) : null}
{workspaceControls}
{modelUnavailable ? (
@@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: {
0
- ? "Attaching pasted text"
- : flow.submitting
- ? "Starting task"
- : attachmentsUploading
- ? "Queue task, sends when uploads finish"
- : environmentConnected
- ? "Start task"
- : "Queue task")
+ (cloneBlocksStart
+ ? projectClone === null || projectClone.phase === "running"
+ ? "Cloning repository"
+ : "Repository not cloned"
+ : pendingPastedTextAttachmentCount > 0
+ ? "Attaching pasted text"
+ : flow.submitting
+ ? "Starting task"
+ : attachmentsUploading
+ ? "Queue task, sends when uploads finish"
+ : environmentConnected
+ ? "Start task"
+ : "Queue task")
}
disabled={!canStart}
icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts
index 8199dee348662d236be82458589e1d7714bf7713..eca98d0563b364910db4b647a4a477c81e33098a 100644
--- a/apps/mobile/src/state/entities.ts
+++ b/apps/mobile/src/state/entities.ts
@@ -1,4 +1,6 @@
import { useAtomValue } from "@effect/atom-react";
+
+import { appAtomRegistry } from "./atom-registry";
import type {
EnvironmentProject,
EnvironmentThreadShell,
@@ -25,6 +27,31 @@ const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe(
Atom.withLabel("mobile-server-config:empty"),
);
+/** Resolves when the project event reaches the live client store. */
+export function waitForProject(
+ ref: ScopedProjectRef,
+ timeoutMs = 10_000,
+): Promise {
+ const atom = environmentProjects.projectAtom(ref);
+ const current = appAtomRegistry.get(atom);
+ if (current !== null) return Promise.resolve(current);
+ return new Promise((resolve) => {
+ let unsubscribe: (() => void) | null = null;
+ const timeout = setTimeout(() => {
+ unsubscribe?.();
+ resolve(null);
+ }, timeoutMs);
+ const finish = (project: EnvironmentProject | null) => {
+ if (project === null) return;
+ clearTimeout(timeout);
+ unsubscribe?.();
+ resolve(project);
+ };
+ unsubscribe = appAtomRegistry.subscribe(atom, finish);
+ finish(appAtomRegistry.get(atom));
+ });
+}
+
export function useProjects(): ReadonlyArray {
return useAtomValue(environmentProjects.projectsAtom);
}
diff --git a/apps/mobile/src/state/projectClones.ts b/apps/mobile/src/state/projectClones.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9a86411e099d7dcb1f9713b329cdd888ff039226
--- /dev/null
+++ b/apps/mobile/src/state/projectClones.ts
@@ -0,0 +1,55 @@
+import { useAtomValue } from "@effect/atom-react";
+import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment";
+import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+
+import { serverEnvironment } from "./server";
+import { sourceControlEnvironment } from "./sourceControl";
+
+/**
+ * `"pending"` while the environment's clone stream has not delivered its
+ * first list yet: the draft opens right after a clone starts, and a caller
+ * that treated the gap as "no clone" would enable Start for a moment.
+ */
+export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null;
+
+const EMPTY_CLONES: ReadonlyArray = [];
+const EMPTY_CLONE_ATOM = Atom.make(null).pipe(
+ Atom.withLabel("mobile-project-clone:empty"),
+);
+
+/**
+ * Latest clone list an environment has streamed, `"pending"` until the first
+ * one, and never subscribed on servers that predate clone tracking.
+ */
+const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get): ReadonlyArray | "pending" => {
+ const config = get(serverEnvironment.configValueAtom(environmentId));
+ if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES;
+ const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} }));
+ // A failed subscription must not hold Start forever. Treating it as
+ // "no clone tracked" lets the server's own dispatch guard decide; the
+ // registry re-establishes the stream on reconnect.
+ if (result._tag === "Failure") return EMPTY_CLONES;
+ return Option.getOrElse(AsyncResult.value(result), () => "pending" as const);
+ }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
+);
+
+const projectCloneAtom = Atom.family((key: string) => {
+ const ref = parseScopedProjectKey(key);
+ return Atom.make((get): ProjectCloneState => {
+ if (ref === null) return null;
+ const clones = get(environmentProjectClonesAtom(ref.environmentId));
+ if (clones === "pending") return "pending";
+ return clones.find((clone) => clone.projectId === ref.projectId) ?? null;
+ }).pipe(Atom.withLabel(`mobile-project-clone:${key}`));
+});
+
+/**
+ * The tracked clone for a project: `"pending"` before the stream's first
+ * list, null once it finished or never started.
+ */
+export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState {
+ return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref)));
+}