From 549d182aaadbf0e1e195d1a8cdc1805200e6751e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 13:00:29 -0700 Subject: [PATCH] feat(mobile): clone repositories in the background and gate the draft on the clone (#11774) Co-authored-by: Claude Fable 5 --- .../src/components/ProjectCloneBanner.tsx | 81 ++++++++++++ .../features/projects/AddProjectScreen.tsx | 62 ++++++++- .../threads/NewTaskDraftRouteScreen.tsx | 3 + .../features/threads/NewTaskDraftScreen.tsx | 122 ++++++++++++++++-- apps/mobile/src/state/entities.ts | 27 ++++ apps/mobile/src/state/projectClones.ts | 55 ++++++++ 6 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/components/ProjectCloneBanner.tsx create mode 100644 apps/mobile/src/state/projectClones.ts 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))); +} -- 2.54.0