| author | |
| committer | |
| log | 549d182aaadbf0e1e195d1a8cdc1805200e6751e |
| tree | c282279557c316036724112d3441afeba2f38fe9 |
| parent | 8d7c700c1788c4f42932cd3be541b3c630bd664d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import { | ||
| 2 | projectCloneDisplayName, | ||
| 3 | projectCloneProgressSummary, | ||
| 4 | type ProjectCloneSnapshot, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { ActivityIndicator, Pressable, View } from "react-native"; | ||
| 7 | |||
| 8 | import { cn } from "../lib/cn"; | ||
| 9 | import { 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 | */ | ||
| 16 | export 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 | |||
| 70 | function 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"; |
| 49 | import { AsyncResult } from "effect/unstable/reactivity"; | 49 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 50 | import { cn } from "../../lib/cn"; | 50 | import { cn } from "../../lib/cn"; |
| 51 | 51 | ||
| 52 | import { useProjects, useServerConfigs } from "../../state/entities"; | 52 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; |
| 53 | import { filesystemEnvironment } from "../../state/filesystem"; | 53 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 54 | import { projectEnvironment } from "../../state/projects"; | 54 | import { projectEnvironment } from "../../state/projects"; |
| 55 | import { useEnvironmentQuery } from "../../state/query"; | 55 | import { 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 | } |
| 81 | 83 | ||
| 82 | const environmentOptionOrder = Order.mapInput( | 84 | const 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 | } |
| 577 | 580 | ||
| 581 | function 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 | |||
| 578 | function useCreateProject(environment: EnvironmentOption | null) { | 590 | function 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 | } |
| 939 | 955 | ||
| 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 | ]); |
| 966 | 1026 | ||
| 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 2 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; | 4 | import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; |
| 3 | import { | 5 | import { |
| 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"; |
| 105 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; | 107 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 108 | import { useProjectClone } from "../../state/projectClones"; | ||
| 109 | import { projectEnvironment } from "../../state/projects"; | ||
| 110 | import { sourceControlEnvironment } from "../../state/sourceControl"; | ||
| 111 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 112 | import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; | ||
| 106 | import { | 113 | import { |
| 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 = selectedProject | 275 | 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> |
| 1481 | 1581 | ||
| 1482 | {modelUnavailable ? ( | 1582 | {modelUnavailable ? ( |
| ... | @@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1618,15 +1718,19 @@ export function NewTaskDraftScreen(props: { |
| 1618 | <ComposerActionButton | 1718 | <ComposerActionButton |
| 1619 | accessibilityLabel={ | 1719 | accessibilityLabel={ |
| 1620 | attachmentBlockReason ?? | 1720 | attachmentBlockReason ?? |
| 1621 | (pendingPastedTextAttachmentCount > 0 | 1721 | (cloneBlocksStart |
| 1622 | ? "Attaching pasted text" | 1722 | ? projectClone === null || projectClone.phase === "running" |
| 1623 | : flow.submitting | 1723 | ? "Cloning repository" |
| 1624 | ? "Starting task" | 1724 | : "Repository not cloned" |
| 1625 | : attachmentsUploading | 1725 | : pendingPastedTextAttachmentCount > 0 |
| 1626 | ? "Queue task, sends when uploads finish" | 1726 | ? "Attaching pasted text" |
| 1627 | : environmentConnected | 1727 | : 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | |||
| 3 | import { appAtomRegistry } from "./atom-registry"; | ||
| 2 | import type { | 4 | import 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 | ); |
| 27 | 29 | ||
| 30 | /** Resolves when the project event reaches the live client store. */ | ||
| 31 | export 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 | |||
| 28 | export function useProjects(): ReadonlyArray<EnvironmentProject> { | 55 | export 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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; | ||
| 3 | import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { serverEnvironment } from "./server"; | ||
| 8 | import { 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 | */ | ||
| 15 | export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null; | ||
| 16 | |||
| 17 | const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = []; | ||
| 18 | const 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 | */ | ||
| 26 | const 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 | |||
| 39 | const 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 | */ | ||
| 53 | export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState { | ||
| 54 | return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); | ||
| 55 | } | ||