1import { DraftEnvironmentControl } from "./DraftEnvironmentControl";
2import { NativeScrollEdgeEffectsContext } from "../../native/NativeScrollViewMarker";
3import { DraftThreadHeader } from "./DraftThreadHeader";
4import { useComposerAttachmentActions } from "../../state/use-composer-attachment-actions";
5import { useWorkspaceBackNavigation } from "../layout/workspace/useAndroidWorkspaceNavigation";
6import { submittedThreadParams } from "./thread-route-state";
7import { ThreadScreenBinding, ThreadScreenFallback } from "./ThreadScreenHost";
8import { draftThreadShell } from "./draft-thread-shell";
9import { useRememberThreadProject } from "./useRememberThreadProject";
10import { scopedProjectKey } from "../../lib/scopedEntities";
11import { connectionTone } from "../connection/connectionTone";
12import { AppBar } from "../../components/ui/AppBar";
13import { ThreadId } from "@t3tools/contracts";
14
15import * as Dialog from "../../components/ui/Dialog";
16import { useAtomValue } from "@effect/atom-react";
17import * as Cause from "effect/Cause";
18import { AsyncResult } from "effect/reactivity";
19import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
20import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
21import {
22 CommonActions,
23 StackActions,
24 useIsFocused,
25 useNavigation,
26 usePreventRemove,
27 useRoute,
28} from "@react-navigation/native";
29import { useEffect, useRef, useState } from "react";
30import { Platform, Pressable, View } from "react-native";
31import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
32import { KeyboardController } from "react-native-keyboard-controller";
33
34import {
35 AuthOrchestrationOperateScope,
36 DEFAULT_PROVIDER_INTERACTION_MODE,
37 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
38 resolveEnvironmentMachineKind,
39} from "@t3tools/contracts";
40
41import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
42import { composerContextImportsAtom } from "../../state/use-composer-drafts";
43import { abortComposerDraftOperations } from "../../state/composerDraftOperations";
44import { composerContextSendBlockReason } from "../../lib/composerContext";
45import { ComposerInlineControl } from "../../components/ComposerToolbar";
46
47import {
48 composerAttachmentUploadBlockReason,
49 composerAttachmentsStillUploading,
50 composerAttachmentUploadsAtom,
51} from "../../state/composer-attachment-uploads";
52
53import { SymbolView } from "../../components/ui/AppSymbol";
54import { AppText as Text } from "../../components/ui/AppText";
55import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
56
57import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
58
59import {
60 clearComposerDraftContent,
61 getComposerDraftSnapshot,
62 updateComposerDraftSettings,
63 scheduleUnusedComposerAttachmentCleanup,
64} from "../../state/use-composer-drafts";
65import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
66import { useProjectClone } from "../../state/projectClones";
67import { projectEnvironment } from "../../state/projects";
68import { sourceControlEnvironment } from "../../state/sourceControl";
69import { useAtomCommand } from "../../state/use-atom-command";
70import { ProjectCloneBanner } from "./ProjectCloneBanner";
71import {
72 describeUnavailableModelSelection,
73 isModelSelectionUnavailable,
74 resolveSelectableModelSelection,
75} from "../../lib/modelOptions";
76import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
77import {
78 useRemoteConnections,
79 useRemoteEnvironmentRuntime,
80} from "../../state/use-remote-environment-registry";
81import { useNewTaskFlow } from "./new-task-flow-provider";
82import {
83 nextEnvironmentId,
84 useHardwareKeyboardCommand,
85} from "../keyboard/hardwareKeyboardCommands";
86import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
87import { resolveDraftProjectSelection } from "./new-task-project-selection";
88import {
89 resolveNewTaskBranchLabel,
90 resolveNewTaskWorkspaceLabel,
91} from "./new-task-context-presentation";
92import { incomingShareImports, useIncomingShare } from "../sharing/IncomingShareProvider";
93import { idleShareFlagAtom, idleShareImportPhaseAtom } from "./incomingShareImport";
94import { newTaskFlowKey } from "./new-task-flow-state";
95import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
96import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
97import { appAtomRegistry } from "../../state/atom-registry";
98
99import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
100
101// Below this (keyboard up on iPad, phone landscape) the hero cannot fit above the composer,
102// so the project and environment pickers move into the composer controls instead.
103const SHORT_DRAFT_VIEWPORT_HEIGHT = 380;
104
105function NewTaskWorkspaceIcon(props: {
106 readonly workspaceMode: "local" | "worktree";
107 readonly worktreePath: string | null;
108 readonly size: number;
109}) {
110 if (props.workspaceMode === "local" && props.worktreePath === null) {
111 return (
112 <SymbolView
113 name="folder"
114 size={props.size}
115 tintColorClassName="accent-icon-muted"
116 type="monochrome"
117 />
118 );
119 }
120
121 const boxSize = (14 * props.size) / 16;
122 return (
123 <View
124 className="size-4"
125 style={Platform.OS === "android" ? { width: boxSize, height: boxSize } : undefined}
126 >
127 <SymbolView
128 name="folder"
129 size={props.size}
130 tintColorClassName="accent-icon-muted"
131 type="monochrome"
132 />
133 <View
134 className="absolute -right-1 -bottom-1"
135 style={
136 Platform.OS === "android" ? { right: -boxSize / 4, bottom: -boxSize / 4 } : undefined
137 }
138 >
139 <SymbolView
140 name="arrow.triangle.branch"
141 size={Math.round((9 * props.size) / 16)}
142 tintColorClassName="accent-icon-muted"
143 type="monochrome"
144 />
145 </View>
146 </View>
147 );
148}
149
150export function NewTaskDraftScreen(props: {
151 readonly initialProjectRef?: {
152 readonly environmentId?: string;
153 readonly projectId?: string;
154 readonly branch?: string | null;
155 readonly worktreePath?: string | null;
156 readonly cloning?: boolean;
157 };
158 readonly draftId?: string;
159 readonly incomingShareId?: string;
160}) {
161 const [creationMetadata] = useState(makeTurnCommandMetadata);
162 const projects = useProjects();
163 const flow = useNewTaskFlow();
164 const navigation = useNavigation();
165 const isFocused = useIsFocused();
166 const goBack = useWorkspaceBackNavigation();
167 const currentRoute = useRoute();
168 const flowKey = newTaskFlowKey(currentRoute);
169 const { getShare, isLoading: isIncomingShareInboxLoading } = useIncomingShare();
170 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
171 // selectedProject falls back to the environment's first project; remember only a chosen one.
172 useRememberThreadProject(
173 selectedProject &&
174 selectedProjectKey === scopedProjectKey(selectedProject.environmentId, selectedProject.id)
175 ? selectedProject
176 : null,
177 );
178 const selectedProjectLabel = (() => {
179 if (!selectedProject) return null;
180 const scope = projectScopes.find((candidate) =>
181 candidate.projectRefs.some(
182 (ref) =>
183 ref.environmentId === selectedProject.environmentId &&
184 ref.projectId === selectedProject.id,
185 ),
186 );
187 return scope?.title ?? selectedProject.title;
188 })();
189 const { onReconnectEnvironment } = useRemoteConnections();
190 const environmentRuntime = useRemoteEnvironmentRuntime(selectedProject?.environmentId ?? null);
191 const connectionState = environmentRuntime?.connectionState ?? "offline";
192 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
193 selectedProject?.environmentId ?? null,
194 );
195 const environmentConnected = connectionState === "connected";
196 const canOperate = useEnvironmentScope(
197 selectedProject?.environmentId ?? null,
198 AuthOrchestrationOperateScope,
199 );
200 const projectCloneState = useProjectClone(
201 selectedProject
202 ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id }
203 : null,
204 );
205 const projectClone = projectCloneState === "pending" ? null : projectCloneState;
206 const awaitingKnownClone =
207 projectCloneState === "pending" &&
208 props.initialProjectRef?.cloning === true &&
209 selectedProject !== null &&
210 selectedProject.id === props.initialProjectRef.projectId &&
211 selectedProject.environmentId === props.initialProjectRef.environmentId;
212 const cloneBlocksStart =
213 awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done");
214 const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
215 reportFailure: false,
216 });
217 const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
218 reportFailure: false,
219 });
220 const runCloneAction = async (
221 title: string,
222 action: () => Promise<AsyncResult.AsyncResult<unknown, unknown>>,
223 ) => {
224 const result = await action();
225 if (AsyncResult.isFailure(result)) {
226 const error = Cause.squash(result.cause);
227 Dialog.alert(title, error instanceof Error ? error.message : "An error occurred.");
228 }
229 };
230 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
231 const removeClonedProject = async () => {
232 if (!selectedProject) return;
233 const removed = selectedProject;
234 const result = await deleteProject({
235 environmentId: removed.environmentId,
236 input: { projectId: removed.id },
237 });
238 if (AsyncResult.isFailure(result)) {
239 const error = Cause.squash(result.cause);
240 Dialog.alert(
241 "Failed to remove project",
242 error instanceof Error ? error.message : "An error occurred.",
243 );
244 return;
245 }
246 if (flow.getSelectedProjectKey() === scopedProjectKey(removed.environmentId, removed.id)) {
247 navigation.dispatch(StackActions.replace("Home"));
248 }
249 };
250 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
251 const attachmentBlockReason = selectedProject
252 ? composerAttachmentUploadBlockReason({
253 environmentId: selectedProject.environmentId,
254 attachments: flow.attachments,
255 connected: environmentConnected,
256 serverConfig: selectedEnvironmentServerConfig,
257 states: uploadStates,
258 })
259 : null;
260 const attachmentsUploading =
261 environmentConnected &&
262 selectedProject !== null &&
263 composerAttachmentsStillUploading({
264 environmentId: selectedProject.environmentId,
265 attachments: flow.attachments,
266 serverConfig: selectedEnvironmentServerConfig,
267 states: uploadStates,
268 });
269 const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading;
270 const promptInputRef = useRef<ComposerEditorHandle>(null);
271 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
272 const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
273 const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null;
274 const requestedInitialProjectAvailable = Boolean(
275 props.initialProjectRef?.environmentId &&
276 props.initialProjectRef.projectId &&
277 projects.some(
278 (project) =>
279 project.environmentId === props.initialProjectRef?.environmentId &&
280 project.id === props.initialProjectRef?.projectId,
281 ),
282 );
283 // A route naming a draft that is gone, or whose project is gone, also
284 // returns to the picker; a draft that survives goes along with it.
285 const unopenableRouteDraftKind =
286 props.draftId !== undefined && flow.unopenableRouteDraft?.draftId === props.draftId
287 ? flow.unopenableRouteDraft.kind
288 : null;
289 const pickerDraftId = unopenableRouteDraftKind === "pickProject" ? props.draftId : undefined;
290 const isProjectPickerReturnActive =
291 (isReturningToProjectPicker && !requestedInitialProjectAvailable) ||
292 unopenableRouteDraftKind !== null;
293 const isIncomingShareAwaitingServerConfig = Boolean(
294 incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
295 selectedEnvironmentServerConfig === null,
296 );
297 const routeImportedShare = useAtomValue(
298 flowKey !== undefined && props.incomingShareId
299 ? incomingShareImports.importedAtom(flowKey, props.incomingShareId)
300 : idleShareFlagAtom,
301 );
302 const hasImportedIncomingShare = Boolean(
303 props.incomingShareId &&
304 (routeImportedShare ||
305 (flow.draftKey &&
306 getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId))),
307 );
308 const isIncomingShareUnavailable = Boolean(
309 props.incomingShareId &&
310 !isIncomingShareInboxLoading &&
311 !incomingShare &&
312 !hasImportedIncomingShare,
313 );
314 const shareImportTarget = (() => {
315 if (!props.incomingShareId || flowKey === undefined || !flow.draftKey) return null;
316 if (!selectedProject || !incomingShare || isIncomingShareAwaitingServerConfig) return null;
317 const initialEnvironmentId = props.initialProjectRef?.environmentId;
318 const initialProjectId = props.initialProjectRef?.projectId;
319 if (
320 initialEnvironmentId &&
321 initialProjectId &&
322 (selectedProject.environmentId !== initialEnvironmentId ||
323 selectedProject.id !== initialProjectId)
324 ) {
325 return null;
326 }
327 return {
328 flowKey,
329 routeKey: currentRoute.key,
330 draftKey: flow.draftKey,
331 destination: {
332 environmentId: String(selectedProject.environmentId),
333 projectId: String(selectedProject.id),
334 },
335 shareId: incomingShare.id,
336 };
337 })();
338 const shareImportPhase = useAtomValue(
339 shareImportTarget === null
340 ? idleShareImportPhaseAtom
341 : incomingShareImports.phaseAtom(shareImportTarget),
342 );
343 // Locks on the route, not the target: the target turns null when server config
344 // drops or the project stops matching, while the import keeps writing the draft.
345 const isImportingShare = useAtomValue(
346 flowKey === undefined ? idleShareFlagAtom : incomingShareImports.inFlightAtom(flowKey),
347 );
348 const isIncomingShareTransferPending = Boolean(
349 incomingShare && shareImportPhase !== "cancelled" && !isIncomingShareAwaitingServerConfig,
350 );
351 // A failed import waits in a modal alert, and a cancel finishes its undo even
352 // after the route leaves, so neither holds the route: the cancel can leave as
353 // soon as it is done.
354 const isIncomingShareHoldingRoute =
355 isIncomingShareTransferPending &&
356 shareImportPhase !== "failed" &&
357 shareImportPhase !== "cancelling";
358 const contextImports = useAtomValue(composerContextImportsAtom);
359 const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false;
360 const isComposerInteractionLocked =
361 isIncomingShareTransferPending || flow.submitting || isImportingContext;
362 const { environments, selectedEnvironmentId, switchEnvironment, switchingToEnvironmentId } = flow;
363 const cycleEnvironment = () => {
364 if (isComposerInteractionLocked) return true;
365 const next = nextEnvironmentId(environments, switchingToEnvironmentId ?? selectedEnvironmentId);
366 if (next !== null) void switchEnvironment(next);
367 return true;
368 };
369 const cycleEnvironmentCommands = environments.length > 1 ? (["cycleHost"] as const) : [];
370 useHardwareKeyboardCommand(cycleEnvironmentCommands, cycleEnvironment);
371 const preventRemove =
372 (isIncomingShareHoldingRoute && !isProjectPickerReturnActive) || flow.submitting;
373 usePreventRemove(preventRemove, () => undefined);
374 const isIncomingShareReady =
375 !props.incomingShareId ||
376 (hasImportedIncomingShare && !incomingShare) ||
377 isIncomingShareUnavailable;
378 const appliedInitialProjectKeyRef = useRef<string | null>(null);
379 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- ends the picker return once the project arrives, else replaces the route with the picker next frame
380 useEffect(() => {
381 if (isReturningToProjectPicker && requestedInitialProjectAvailable) {
382 // oxlint-disable-next-line react/set-state-in-effect -- Ends the return-to-picker flow once the requested project arrives from the catalog.
383 setIsReturningToProjectPicker(false);
384 }
385 if (!isProjectPickerReturnActive) {
386 return;
387 }
388 // Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback.
389 const frame = requestAnimationFrame(() => {
390 navigation.dispatch(
391 StackActions.replace("NewTaskProject", {
392 incomingShareId: props.incomingShareId,
393 draftId: pickerDraftId,
394 }),
395 );
396 });
397 return () => cancelAnimationFrame(frame);
398 }, [
399 isProjectPickerReturnActive,
400 isReturningToProjectPicker,
401 navigation,
402 pickerDraftId,
403 props.incomingShareId,
404 requestedInitialProjectAvailable,
405 ]);
406
407 const lastInitialProjectRefRef = useRef(props.initialProjectRef);
408 const initialPickerRef = useRef<"open" | "dismissed" | null>(null);
409
410 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies the route or catalog project to the draft once projects load, or opens the picker in the navigator
411 useEffect(() => {
412 if (props.draftId) {
413 return;
414 }
415 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
416 lastInitialProjectRefRef.current = props.initialProjectRef;
417 appliedInitialProjectKeyRef.current = null;
418 }
419 const initialEnvironmentId = props.initialProjectRef?.environmentId;
420 const initialProjectId = props.initialProjectRef?.projectId;
421 if (initialEnvironmentId && initialProjectId) {
422 const directProject =
423 projects.find(
424 (project) =>
425 project.environmentId === initialEnvironmentId && project.id === initialProjectId,
426 ) ?? null;
427
428 if (directProject) {
429 // Apply the route project once or picker changes would immediately revert.
430 const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
431 if (appliedInitialProjectKeyRef.current === directProjectKey) {
432 return;
433 }
434 if (props.initialProjectRef?.branch) {
435 if (
436 selectedProject?.environmentId !== directProject.environmentId ||
437 selectedProject.id !== directProject.id
438 ) {
439 setProject(directProject);
440 return;
441 }
442 if (!flow.draftKey) return;
443 // Checkout completed before mount; local mode reuses it while worktree mode would create another.
444 updateComposerDraftSettings(flow.draftKey, {
445 workspaceSelection: {
446 mode: "local",
447 branch: props.initialProjectRef.branch,
448 worktreePath: props.initialProjectRef.worktreePath ?? null,
449 startFromOrigin: false,
450 },
451 });
452 }
453 appliedInitialProjectKeyRef.current = directProjectKey;
454 if (
455 selectedProject?.environmentId === directProject.environmentId &&
456 selectedProject.id === directProject.id
457 ) {
458 return;
459 }
460 setProject(directProject);
461 return;
462 }
463
464 if (projects.length > 0) {
465 // oxlint-disable-next-line react/set-state-in-effect -- Route-driven project resolution starts the return-to-picker flow when no project matches.
466 setIsReturningToProjectPicker(true);
467 }
468 return;
469 }
470
471 const selection = resolveDraftProjectSelection(
472 selectedProjectKey,
473 projects,
474 pickableProjectGroups(projectScopes),
475 {
476 incomingShareId: props.incomingShareId,
477 pickerClosed: initialPickerRef.current === "open" && isFocused,
478 },
479 );
480 if (selection.kind === "preserve") {
481 initialPickerRef.current = null;
482 return;
483 }
484 if (selection.kind === "select") {
485 setProject(selection.project);
486 return;
487 }
488 if (selection.kind === "dismiss") {
489 initialPickerRef.current = "dismissed";
490 goBack();
491 return;
492 }
493 // Open the first picker once; catalog updates must not push it again over a cancelled draft.
494 if (initialPickerRef.current !== null) {
495 return;
496 }
497 initialPickerRef.current = "open";
498 navigation.dispatch(CommonActions.navigate("NewTaskProject"));
499 }, [
500 goBack,
501 isFocused,
502 projectScopes,
503 projects,
504 flow.draftKey,
505 props.initialProjectRef,
506 props.incomingShareId,
507 props.draftId,
508 navigation,
509 selectedProject,
510 selectedProjectKey,
511 setProject,
512 ]);
513
514 const { loadBranches } = flow;
515 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests the selected project's branch list from the server once per project
516 useEffect(() => {
517 if (!selectedProject) {
518 loadedBranchesProjectKeyRef.current = null;
519 return;
520 }
521 const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`;
522 if (loadedBranchesProjectKeyRef.current === projectKey) {
523 return;
524 }
525 loadedBranchesProjectKeyRef.current = projectKey;
526 loadBranches();
527 }, [loadBranches, selectedProject]);
528
529 const selectedEnvironmentLabel =
530 flow.environments.find(
531 (environment) => environment.environmentId === flow.selectedEnvironmentId,
532 )?.environmentLabel ?? "Environment";
533 const availableCurrentBranchName =
534 flow.availableBranches.find((branch) => branch.current)?.name ??
535 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
536 null;
537 const selectedBranchName = resolveProjectThreadCreationBranch({
538 workspaceMode: flow.workspaceMode,
539 selectedBranch:
540 flow.selectedBranchName ??
541 (flow.workspaceMode === "worktree" ? availableCurrentBranchName : null),
542 currentCheckoutBranch: flow.currentCheckoutBranchName,
543 });
544 const selectedBranchLabel = resolveNewTaskBranchLabel({
545 branchName: selectedBranchName,
546 startFromOrigin: flow.startFromOrigin,
547 workspaceMode: flow.workspaceMode,
548 pullRequestReference: flow.pullRequestReference,
549 });
550 const workspaceLabel = resolveNewTaskWorkspaceLabel({
551 workspaceMode: flow.workspaceMode,
552 worktreePath: flow.selectedWorktreePath,
553 });
554 const showBranchLoading =
555 environmentConnected && flow.branchesLoading && flow.availableBranches.length === 0;
556
557 const {
558 onPickDraftMedia: handlePickMedia,
559 onPickDraftFiles: handlePickFiles,
560 onNativePasteImages: handleNativePasteImages,
561 onNativePasteText: handleNativePasteText,
562 } = useComposerAttachmentActions({
563 draftKey: flow.draftKey,
564 serverConfig: selectedEnvironmentServerConfig,
565 locked: isComposerInteractionLocked,
566 });
567
568 async function handleStart(): Promise<import("@t3tools/contracts").MessageId | null> {
569 const selectedProject = flow.selectedProject;
570 const draftKey = flow.draftKey;
571 if (
572 !selectedProject ||
573 !draftKey ||
574 cloneBlocksStart ||
575 !isIncomingShareReady ||
576 isImportingShare ||
577 isComposerInteractionLocked
578 ) {
579 return null;
580 }
581 if (
582 environmentConnected &&
583 !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope)
584 ) {
585 return null;
586 }
587 const draft = getComposerDraftSnapshot(draftKey);
588 if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return null;
589 const modelSelection =
590 resolveSelectableModelSelection(
591 selectedEnvironmentServerConfig,
592 draft.modelSelection ?? null,
593 selectedProject.id,
594 ) ?? flow.selectedModel;
595 const workspaceSelection = flow.resolveWorkspaceSelection(draft.workspaceSelection);
596 const workspaceMode = workspaceSelection.mode;
597 const selectedBranchName = workspaceSelection.branch;
598 const initialMessageText = draft.text.trim();
599
600 if (
601 attachmentBlockReason !== null ||
602 !modelSelection ||
603 (initialMessageText.length === 0 && draft.attachments.length === 0) ||
604 flow.submitting ||
605 (workspaceMode === "worktree" && !selectedBranchName)
606 ) {
607 return null;
608 }
609 if (
610 environmentConnected &&
611 isModelSelectionUnavailable(
612 selectedEnvironmentServerConfig,
613 modelSelection,
614 selectedProject.id,
615 )
616 ) {
617 const { title, detail } = describeUnavailableModelSelection(
618 selectedEnvironmentServerConfig,
619 modelSelection,
620 selectedProject.id,
621 );
622 Dialog.alert(title, detail);
623 return null;
624 }
625 // Recovery may retain over-cap attachments; block send before uploading a turn the server would reject.
626 if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
627 Dialog.alert(
628 "Too many attachments",
629 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
630 );
631 return null;
632 }
633
634 const contextBlockReason = composerContextSendBlockReason(draft.context);
635 if (contextBlockReason) {
636 Dialog.alert("Too much context", contextBlockReason);
637 return null;
638 }
639 const strandedThread = threadContextsOutsideEnvironment({
640 text: draft.text,
641 records: draft.context?.records ?? [],
642 environmentId: selectedProject.environmentId,
643 linkedEnvironments: flow.linkedEnvironments,
644 })[0];
645 if (strandedThread) {
646 const owner =
647 flow.environments.find(
648 (environment) => environment.environmentId === strandedThread.environmentId,
649 )?.environmentLabel ?? "its machine";
650 Dialog.alert(
651 "Attached thread unavailable",
652 `"${strandedThread.title}" is on another machine. Switch back to ${owner} or remove it from the draft.`,
653 );
654 return null;
655 }
656
657 const message = flow.buildPendingTaskMessage(creationMetadata, {
658 currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
659 });
660 if (!message) {
661 return null;
662 }
663 // Persist locally before clearing or leaving the draft; server setup runs on the thread.
664 flow.setSubmitting(true);
665 const queued = await settlePromise(() => enqueueThreadOutboxMessage(message));
666 flow.setSubmitting(false);
667 if (AsyncResult.isFailure(queued)) {
668 const error = squashAtomCommandFailure(queued);
669 Dialog.alert(
670 "Could not queue task",
671 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
672 );
673 return null;
674 }
675 promptInputRef.current?.blur();
676 navigation.dispatch(
677 CommonActions.setParams(
678 submittedThreadParams(String(message.environmentId), String(message.threadId)),
679 ),
680 );
681 // The composer has moved to the submitted thread; a pick still converting
682 // would otherwise resurrect this draft.
683 abortComposerDraftOperations(draftKey);
684 const draftSnapshot = getComposerDraftSnapshot(draftKey);
685 // The queued message owns the attachments now, so the sweep is deferred
686 // until the write confirms it.
687 clearComposerDraftContent(draftKey, {
688 clearModelSelection: true,
689 clearWorkspaceSelection: true,
690 deferAttachmentCleanup: true,
691 });
692 scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
693 return message.messageId;
694 }
695
696 // Keep the current surface until the local draft selection is ready. Publishing
697 // an empty loading surface here flashes between two already-cached threads.
698 if (!selectedProject) return null;
699
700 const setupBlockReason =
701 isIncomingShareReady && !isImportingShare
702 ? cloneBlocksStart
703 ? "Wait for the project clone to finish."
704 : flow.submitting
705 ? "Starting the thread…"
706 : flow.workspaceMode === "worktree" && (!flow.selectedBranchName || !flow.isGitRepoKnown)
707 ? "Choose a base branch before creating a worktree."
708 : null
709 : "Wait for shared content to finish importing.";
710 const chooseProject = () => {
711 if (isComposerInteractionLocked) {
712 return;
713 }
714 promptInputRef.current?.blur();
715 void KeyboardController.dismiss({ animated: true });
716 navigation.navigate("NewTaskProject", { incomingShareId: props.incomingShareId });
717 };
718 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
719 if (isComposerInteractionLocked) {
720 return;
721 }
722 promptInputRef.current?.blur();
723 void KeyboardController.dismiss({ animated: true });
724 navigation.navigate(routeName);
725 };
726
727 const environmentControl = (
728 <DraftEnvironmentControl
729 connectionState={connectionState}
730 label={selectedEnvironmentLabel}
731 machineKind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
732 disabled={isComposerInteractionLocked}
733 onChooseEnvironment={() => openContextPicker("NewTaskEnvironment")}
734 />
735 );
736 const shareUnavailableNotice = isIncomingShareUnavailable ? (
737 <Text textRole="footnote" className="text-center text-foreground-muted">
738 The shared content is no longer in the inbox. You can continue editing this task draft.
739 </Text>
740 ) : null;
741 const hero = flow.isScratchDraft ? (
742 <View className="items-center gap-2 px-6" testID="new-task-hero">
743 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
744 What should we work on?
745 </Text>
746 <View className="flex-row flex-wrap items-center justify-center gap-x-1">
747 <ComposerInlineControl
748 accessibilityHint="Opens the project picker"
749 accessibilityLabel="No project"
750 chevronDirection="right"
751 disabled={isComposerInteractionLocked}
752 icon="folder"
753 label="No project"
754 onPress={chooseProject}
755 />
756 {environmentControl}
757 </View>
758 {shareUnavailableNotice}
759 </View>
760 ) : (
761 <View className="items-center gap-6 px-6" testID="new-task-hero">
762 <View className="w-full items-center gap-1.5">
763 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
764 What should we build
765 </Text>
766 <View className="max-w-full flex-row items-center justify-center">
767 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text>
768 <Pressable
769 accessibilityHint="Opens the project picker"
770 accessibilityLabel={selectedProjectLabel ?? selectedProject.title}
771 accessibilityRole="button"
772 disabled={isComposerInteractionLocked}
773 onPress={chooseProject}
774 className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"
775 >
776 <Text
777 className="text-2xl font-t3-medium tracking-tight text-foreground"
778 numberOfLines={1}
779 >
780 {selectedProjectLabel ?? selectedProject.title}
781 </Text>
782 </Pressable>
783 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">?</Text>
784 </View>
785 </View>
786
787 {environmentControl}
788 {shareUnavailableNotice}
789 </View>
790 );
791 // The nested hero scroll view does not receive UIKit's automatic navigation inset in split columns.
792 const heroViewport = (headerInset: number, bottomInset: number, viewportHeight: number) =>
793 viewportHeight < SHORT_DRAFT_VIEWPORT_HEIGHT ? null : (
794 <NativeScrollEdgeEffectsContext
795 value={{ top: "hidden", bottom: "hidden", left: "hidden", right: "hidden" }}
796 >
797 <View
798 collapsable={false}
799 style={{
800 position: "absolute",
801 top: headerInset,
802 bottom: bottomInset,
803 left: 0,
804 right: 0,
805 overflow: "hidden",
806 }}
807 >
808 <ScrollView
809 alwaysBounceVertical={false}
810 className="flex-1"
811 contentInsetAdjustmentBehavior="never"
812 contentContainerClassName="grow items-center"
813 contentContainerStyle={{ paddingVertical: 24 }}
814 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
815 keyboardShouldPersistTaps="handled"
816 showsVerticalScrollIndicator={false}
817 style={{ flex: 1 }}
818 testID="new-task-hero-scroll"
819 >
820 <View style={{ flexGrow: 1 }} />
821 {hero}
822 <View style={{ flexGrow: 2 }} />
823 </ScrollView>
824 </View>
825 </NativeScrollEdgeEffectsContext>
826 );
827
828 const contextProjectLabel = flow.isScratchDraft
829 ? "No project"
830 : (selectedProjectLabel ?? selectedProject.title);
831 const contextControls = (
832 <View className="flex-row items-center gap-1 px-2">
833 <View className="min-w-0 shrink">
834 <ComposerInlineControl
835 accessibilityHint="Opens the project picker"
836 accessibilityLabel={`Project: ${contextProjectLabel}`}
837 chevronDirection="right"
838 disabled={isComposerInteractionLocked}
839 icon="folder"
840 label={contextProjectLabel}
841 maxWidth="100%"
842 onPress={chooseProject}
843 />
844 </View>
845 <View className="min-w-0 shrink">
846 <ComposerInlineControl
847 accessibilityLabel={`Choose environment, ${selectedEnvironmentLabel}, ${connectionState === "connected" ? "Connected" : "Offline"}`}
848 chevronDirection="right"
849 disabled={isComposerInteractionLocked}
850 renderIcon={(size) => (
851 <EnvironmentMachineSymbol
852 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
853 size={size}
854 tintColorClassName="accent-icon-muted"
855 />
856 )}
857 label={selectedEnvironmentLabel}
858 maxWidth="100%"
859 onPress={() => openContextPicker("NewTaskEnvironment")}
860 suffix={
861 connectionState === "connected" ? null : (
862 <ConnectionStatusDot state="error" pulse={false} size={6} />
863 )
864 }
865 />
866 </View>
867 </View>
868 );
869
870 const workspaceControls = (
871 <View className="flex-row items-center gap-1 px-2">
872 <ComposerInlineControl
873 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
874 accessibilityLabel={workspaceLabel}
875 disabled={isComposerInteractionLocked || !flow.isGitRepo}
876 renderIcon={(size) => (
877 <NewTaskWorkspaceIcon
878 workspaceMode={flow.workspaceMode}
879 worktreePath={flow.selectedWorktreePath}
880 size={size}
881 />
882 )}
883 label={workspaceLabel}
884 maxWidth="100%"
885 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
886 showChevron={false}
887 />
888
889 <View className="min-w-0 shrink">
890 <ComposerInlineControl
891 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
892 chevronDirection="right"
893 disabled={isComposerInteractionLocked || !flow.isGitRepo}
894 icon="arrow.triangle.branch"
895 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
896 maxWidth="100%"
897 onPress={() => openContextPicker("NewTaskBranch")}
898 />
899 </View>
900 </View>
901 );
902
903 const draftControls = (viewportHeight: number) => (
904 <View className="pb-1">
905 {projectClone && projectClone.phase !== "done" && selectedProject ? (
906 <View className="px-1 pb-2">
907 <ProjectCloneBanner
908 clone={projectClone}
909 onCancel={() =>
910 void runCloneAction("Failed to cancel clone", () =>
911 cancelProjectClone({
912 environmentId: selectedProject.environmentId,
913 input: { projectId: selectedProject.id },
914 }),
915 )
916 }
917 onRetry={() =>
918 void runCloneAction("Failed to retry clone", () =>
919 retryProjectClone({
920 environmentId: selectedProject.environmentId,
921 input: { projectId: selectedProject.id },
922 }),
923 )
924 }
925 onRemove={() => void removeClonedProject()}
926 />
927 </View>
928 ) : null}
929 {viewportHeight < SHORT_DRAFT_VIEWPORT_HEIGHT ? contextControls : null}
930 {flow.canChooseWorkspace ? workspaceControls : null}
931 </View>
932 );
933
934 if (!flow.selectedModel) {
935 return (
936 <ThreadScreenFallback header={<AppBar title="New thread" onBack={goBack} />}>
937 <View className="flex-1 justify-end gap-3 p-4">
938 {environmentControl}
939 <Text className="text-foreground-muted">
940 Your draft is saved on this device. Connect to an environment to choose its first model.
941 </Text>
942 <ComposerEditor
943 draftKey={flow.draftKey}
944 value={flow.prompt}
945 onChangeText={flow.setPrompt}
946 placeholder="Ask anything"
947 multiline
948 style={{ minHeight: 64 }}
949 />
950 </View>
951 </ThreadScreenFallback>
952 );
953 }
954 const thread = draftThreadShell({
955 environmentId: selectedProject.environmentId,
956 projectId: selectedProject.id,
957 threadId: ThreadId.make(creationMetadata.threadId),
958 modelSelection: flow.selectedModel,
959 runtimeMode: flow.runtimeMode,
960 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
961 branch: flow.selectedBranchName,
962 worktreePath: flow.selectedWorktreePath,
963 createdAt: creationMetadata.createdAt,
964 });
965 return (
966 <>
967 <ThreadScreenBinding
968 environmentId={selectedProject.environmentId}
969 header={
970 <DraftThreadHeader thread={thread} project={selectedProject} cwd={flow.workspaceCwd} />
971 }
972 draftMode={{
973 content: heroViewport,
974 controls: draftControls,
975 editorRef: promptInputRef,
976 sendBlockedReason: setupBlockReason,
977 interactionLocked: isComposerInteractionLocked,
978 }}
979 canOperateThread={canOperate}
980 selectedThread={thread}
981 selectedThreadDetailRef={null}
982 contentPresentation={{ kind: "ready" }}
983 screenTone={connectionTone(connectionState)}
984 connectionError={environmentRuntime?.connectionError ?? null}
985 environmentLabel={selectedEnvironmentLabel}
986 feedbackSubmissions={[]}
987 onDismissFeedback={() => {}}
988 selectedThreadFeed={[]}
989 activityRun={null}
990 activeWorkStartedAt={null}
991 isCompacting={false}
992 creationState={null}
993 activePendingApproval={null}
994 activePendingApprovals={[]}
995 respondingApprovalId={null}
996 activePendingUserInput={null}
997 activePendingUserInputDrafts={{}}
998 activePendingUserInputAnswers={null}
999 respondingUserInputId={null}
1000 connectionStateLabel={connectionState}
1001 activeThreadBusy={false}
1002 canStopThread={false}
1003 queuedRunEdit={null}
1004 composerDraftKey={flow.draftKey}
1005 followUpBehavior="queue"
1006 canSteerActiveTurn={false}
1007 isSavingQueuedEdit={false}
1008 onCancelQueuedRunEdit={() => {}}
1009 onRemoveQueuedEditAttachment={() => {}}
1010 projectWorkspaceRoot={selectedProject.workspaceRoot}
1011 threadCwd={flow.workspaceCwd}
1012 selectedThreadQueueCount={0}
1013 queuedMessages={[]}
1014 dispatchingMessageId={null}
1015 serverConfig={selectedEnvironmentServerConfig}
1016 usesAutomaticContentInsets
1017 onOpenConnectionEditor={() => navigation.navigate("Connections")}
1018 onChangeDraftMessage={flow.setPrompt}
1019 onPickDraftMedia={handlePickMedia}
1020 onPickDraftFiles={handlePickFiles}
1021 onNativePasteImages={handleNativePasteImages}
1022 onNativePasteText={handleNativePasteText}
1023 onRemoveDraftImage={flow.removeAttachment}
1024 onStopThread={() => {}}
1025 onResumeQueue={null}
1026 onSendMessage={handleStart}
1027 offersSideChat={false}
1028 onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)}
1029 canSwitchThreadProvider
1030 onRespondToApproval={async () => {}}
1031 onSelectUserInputOption={() => {}}
1032 onChangeUserInputCustomAnswer={() => {}}
1033 onSubmitUserInput={async () => {}}
1034 onDismissUserInput={async () => {}}
1035 />
1036 </>
1037 );
1038}