1import { useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
5import {
6 nextPastedTextFileName,
7 pastedTextDisposition,
8 replaceTextSelection,
9} from "@t3tools/client-runtime/text-paste";
10import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
11import {
12 CommonActions,
13 StackActions,
14 useFocusEffect,
15 useNavigation,
16 usePreventRemove,
17 type NavigationAction,
18} from "@react-navigation/native";
19import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
21import {
22 KeyboardController,
23 KeyboardStickyView,
24 useKeyboardState,
25} from "react-native-keyboard-controller";
26import Animated from "react-native-reanimated";
27import { useSafeAreaInsets } from "react-native-safe-area-context";
28import { useUniwindTheme } from "../../lib/useUniwindTheme";
29import { useFontFamily } from "../../lib/useFontFamily";
30import {
31 AuthOrchestrationOperateScope,
32 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
33 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
34 resolveEnvironmentMachineKind,
35} from "@t3tools/contracts";
36import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
37
38import {
39 ComposerEditor,
40 type ComposerEditorHandle,
41 type ComposerTextPaste,
42} from "../../components/ComposerEditor";
43import { composerContextImportsAtom } from "../../state/use-composer-drafts";
44import {
45 composerContextSendBlockReason,
46 type ComposerDocumentAttachment,
47} from "../../lib/composerContext";
48import {
49 ComposerActionButton,
50 ComposerInlineControl,
51 ComposerToolbarRow,
52} from "../../components/ComposerToolbar";
53import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
54import { MaterialScreenContent } from "../../components/MaterialScreenContent";
55import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
56import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
57import { composerStripAttachments } from "../../lib/composerImages";
58import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
59import {
60 composerAttachmentUploadBlockReason,
61 composerAttachmentsStillUploading,
62 composerAttachmentUploadsAtom,
63} from "../../state/composer-attachment-uploads";
64import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
65import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
66import { ProviderIcon } from "../../components/ProviderIcon";
67import { SymbolView } from "../../components/AppSymbol";
68import { AppText as Text } from "../../components/AppText";
69import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
70import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
71import { ComposerCommandPopover } from "./ComposerCommandPopover";
72import { useComposerCommandMenu } from "./use-composer-command-menu";
73import {
74 ComposerDictationCancelAction,
75 ComposerDictationPrimaryAction,
76 ComposerDictationStatus,
77 ComposerDictationToolbar,
78} from "../voice-input/ComposerDictationControl";
79import { useVoiceInputController } from "../voice-input/useVoiceInputController";
80import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";
81import {
82 useThreadSettingsSheetPresentation,
83 type NavigationWithFinishTransitioning,
84} from "./use-thread-settings-sheet-presentation";
85import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
86import {
87 convertPastedImagesToAttachments,
88 createPastedTextComposerAttachment,
89 pickComposerFiles,
90 pickComposerMedia,
91 removePersistedComposerAttachmentFile,
92 type DraftComposerFileAttachment,
93} from "../../lib/composerImages";
94import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
95import {
96 clearComposerDraftContent,
97 captureComposerDraftInsertion,
98 countComposerDraftAttachmentsAfterSelection,
99 getComposerDraftSnapshot,
100 composerDraftsAtom,
101 mergeComposerDraftContent,
102 restoreComposerDraftSnapshot,
103 updateComposerDraftSettings,
104 scheduleUnusedComposerAttachmentCleanup,
105 type ComposerDraft,
106 waitForComposerDraftsLoaded,
107} from "../../state/use-composer-drafts";
108import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities";
109import { useProjectClone } from "../../state/projectClones";
110import { projectEnvironment } from "../../state/projects";
111import { sourceControlEnvironment } from "../../state/sourceControl";
112import { useAtomCommand } from "../../state/use-atom-command";
113import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";
114import {
115 describeUnavailableModelSelection,
116 isModelSelectionUnavailable,
117 resolveSelectableModelSelection,
118} from "../../lib/modelOptions";
119import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
120import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
121import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
122import { useNewTaskFlow } from "./new-task-flow-provider";
123import {
124 nextEnvironmentId,
125 useHardwareKeyboardCommand,
126} from "../keyboard/hardwareKeyboardCommands";
127import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
128import { resolveDraftProjectSelection } from "./new-task-project-selection";
129import {
130 resolveNewTaskBranchLabel,
131 resolveNewTaskWorkspaceLabel,
132} from "./new-task-context-presentation";
133import { useIncomingShare } from "../sharing/IncomingShareProvider";
134import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model";
135import { appAtomRegistry } from "../../state/atom-registry";
136import { serverEnvironment } from "../../state/server";
137import { fileRoutePathSegments } from "../files/filePath";
138import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
139
140function NewTaskWorkspaceIcon(props: {
141 readonly workspaceMode: "local" | "worktree";
142 readonly worktreePath: string | null;
143 readonly size: number;
144}) {
145 if (props.workspaceMode === "local" && props.worktreePath === null) {
146 return (
147 <SymbolView
148 name="folder"
149 size={props.size}
150 tintColorClassName="accent-icon-muted"
151 type="monochrome"
152 />
153 );
154 }
155
156 const boxSize = (14 * props.size) / 16;
157 return (
158 <View
159 className="size-4"
160 style={Platform.OS === "android" ? { width: boxSize, height: boxSize } : undefined}
161 >
162 <SymbolView
163 name="folder"
164 size={props.size}
165 tintColorClassName="accent-icon-muted"
166 type="monochrome"
167 />
168 <View
169 className="absolute -right-1 -bottom-1"
170 style={
171 Platform.OS === "android" ? { right: -boxSize / 4, bottom: -boxSize / 4 } : undefined
172 }
173 >
174 <SymbolView
175 name="arrow.triangle.branch"
176 size={Math.round((9 * props.size) / 16)}
177 tintColorClassName="accent-icon-muted"
178 type="monochrome"
179 />
180 </View>
181 </View>
182 );
183}
184
185export function NewTaskDraftScreen(props: {
186 readonly initialProjectRef?: {
187 readonly environmentId?: string;
188 readonly projectId?: string;
189 readonly branch?: string | null;
190 readonly worktreePath?: string | null;
191 /** The project was just added by a clone that is still running. */
192 readonly cloning?: boolean;
193 };
194 /** Queued outbox message id when editing an existing pending task. */
195 readonly pendingTaskId?: string;
196 /** Existing new-task draft key to resume (a Draft row in the thread list). */
197 readonly draftId?: string;
198 /** Durable native share inbox item to merge into this project draft. */
199 readonly incomingShareId?: string;
200}) {
201 const projects = useProjects();
202 const flow = useNewTaskFlow();
203 const navigation = useNavigation();
204 const {
205 consumeShare,
206 getShare,
207 isLoading: isIncomingShareInboxLoading,
208 releaseShareReservation,
209 reserveShare,
210 } = useIncomingShare();
211 const insets = useSafeAreaInsets();
212 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
213 const controlsBottomPadding = Math.max(insets.bottom, 10);
214 const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
215 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
216 // The picker and sidebar name a project by its repository scope; a workspace
217 // inside it (e.g. apps/server) is titled by its folder, which reads as
218 // ambiguous on its own.
219 const selectedProjectLabel = useMemo(() => {
220 if (!selectedProject) return null;
221 const scope = projectScopes.find((candidate) =>
222 candidate.projectRefs.some(
223 (ref) =>
224 ref.environmentId === selectedProject.environmentId &&
225 ref.projectId === selectedProject.id,
226 ),
227 );
228 return scope?.title ?? selectedProject.title;
229 }, [projectScopes, selectedProject]);
230 const { connectedEnvironments } = useRemoteConnectionStatus();
231 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
232 selectedProject?.environmentId ?? null,
233 );
234 const environmentConnected =
235 selectedProject !== null &&
236 connectedEnvironments.find(
237 (environment) => environment.environmentId === selectedProject.environmentId,
238 )?.connectionState === "connected";
239 const canOperate = useEnvironmentScope(
240 selectedProject?.environmentId ?? null,
241 AuthOrchestrationOperateScope,
242 );
243 const taskPermissionReason =
244 environmentConnected && !canOperate ? "This connection cannot start tasks." : null;
245 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
246 // A project added by cloning exists before its files do: the prompt can be
247 // written meanwhile, but Start waits for the clone.
248 const projectCloneState = useProjectClone(
249 selectedProject
250 ? { environmentId: selectedProject.environmentId, projectId: selectedProject.id }
251 : null,
252 );
253 const projectClone = projectCloneState === "pending" ? null : projectCloneState;
254 // Before the clone stream delivers, only the project this draft was opened
255 // for by Add Project is known to be cloning; any other project (offline
256 // included) must still be able to queue a task.
257 const awaitingKnownClone =
258 projectCloneState === "pending" &&
259 props.initialProjectRef?.cloning === true &&
260 selectedProject !== null &&
261 selectedProject.id === props.initialProjectRef.projectId &&
262 selectedProject.environmentId === props.initialProjectRef.environmentId;
263 const cloneBlocksStart =
264 awaitingKnownClone || (projectClone !== null && projectClone.phase !== "done");
265 const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
266 reportFailure: false,
267 });
268 const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
269 reportFailure: false,
270 });
271 // The banner only reflects the server's state, so a request that never got
272 // there needs its own feedback.
273 const runCloneAction = async (
274 title: string,
275 action: () => Promise<AsyncResult.AsyncResult<unknown, unknown>>,
276 ) => {
277 const result = await action();
278 if (AsyncResult.isFailure(result)) {
279 const error = Cause.squash(result.cause);
280 Alert.alert(title, error instanceof Error ? error.message : "An error occurred.");
281 }
282 };
283 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
284 // The delete is awaited; by then the picker may point somewhere else, and
285 // only the removed project's draft should leave the screen.
286 const selectedProjectRef = useRef(selectedProject);
287 useEffect(() => {
288 selectedProjectRef.current = selectedProject;
289 }, [selectedProject]);
290 const removeClonedProject = async () => {
291 if (!selectedProject) return;
292 const removed = selectedProject;
293 const result = await deleteProject({
294 environmentId: removed.environmentId,
295 input: { projectId: removed.id },
296 });
297 if (AsyncResult.isFailure(result)) {
298 const error = Cause.squash(result.cause);
299 Alert.alert(
300 "Failed to remove project",
301 error instanceof Error ? error.message : "An error occurred.",
302 );
303 return;
304 }
305 const current = selectedProjectRef.current;
306 if (current?.id === removed.id && current.environmentId === removed.environmentId) {
307 navigation.dispatch(StackActions.replace("Home"));
308 }
309 };
310 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
311 const attachmentBlockReason = selectedProject
312 ? composerAttachmentUploadBlockReason({
313 environmentId: selectedProject.environmentId,
314 attachments: flow.attachments,
315 connected: environmentConnected,
316 serverConfig: selectedEnvironmentServerConfig,
317 states: uploadStates,
318 })
319 : null;
320 // A connected composer with uploads still in flight queues the task rather
321 // than making the user wait: the outbox drain finishes the upload and sends.
322 const attachmentsUploading =
323 environmentConnected &&
324 selectedProject !== null &&
325 composerAttachmentsStillUploading({
326 environmentId: selectedProject.environmentId,
327 attachments: flow.attachments,
328 serverConfig: selectedEnvironmentServerConfig,
329 states: uploadStates,
330 });
331 const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading;
332 const promptInputRef = useRef<ComposerEditorHandle>(null);
333 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
334 const [isComposerFocused, setIsComposerFocused] = useState(false);
335 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
336 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
337 const wasFocusedBeforePreviewRef = useRef(false);
338 const openVideoPreview = useCallback(
339 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
340 wasFocusedBeforePreviewRef.current = isComposerFocused;
341 setPreviewFile(null);
342 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
343 },
344 [isComposerFocused],
345 );
346 const openFilePreview = useCallback(
347 (source: FilePreviewSource) => {
348 wasFocusedBeforePreviewRef.current = isComposerFocused;
349 setPreviewVideo(null);
350 setPreviewFile((current) => current ?? source);
351 },
352 [isComposerFocused],
353 );
354 const closeMediaPreview = useCallback(() => {
355 setPreviewVideo(null);
356 setPreviewFile(null);
357 if (wasFocusedBeforePreviewRef.current) {
358 setTimeout(() => {
359 if (navigation.isFocused()) promptInputRef.current?.focus();
360 }, 100);
361 }
362 }, [navigation]);
363 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
364 editorRef: promptInputRef,
365 isEditorFocused: isComposerFocused,
366 });
367 useEffect(() => {
368 if (Platform.OS !== "ios") {
369 return;
370 }
371
372 navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible });
373 }, [isKeyboardVisible, navigation]);
374 useEffect(() => {
375 return () => {
376 if (Platform.OS === "ios") {
377 navigation.getParent()?.setOptions({ gestureEnabled: true });
378 }
379 };
380 }, [navigation]);
381 const settingsRoutePresentedRef = useRef(false);
382 useEffect(() => {
383 if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
384 return;
385 }
386
387 settingsRoutePresentedRef.current = true;
388 navigation.dispatch(StackActions.push("ThreadSettings"));
389 }, [navigation, settingsSheetPresentation.isVisible]);
390 useFocusEffect(
391 useCallback(() => {
392 if (!settingsRoutePresentedRef.current) {
393 return;
394 }
395
396 settingsRoutePresentedRef.current = false;
397 settingsSheetPresentation.onDismissed();
398 }, [settingsSheetPresentation.onDismissed]),
399 );
400 useEffect(
401 () =>
402 // UIKit's completion callback for the sheet dismissal, surfaced by the
403 // native-stack patch. This is when the queued keyboard restore runs.
404 (navigation as unknown as NavigationWithFinishTransitioning).addListener(
405 "finishTransitioning",
406 settingsSheetPresentation.onStackTransitionsFinished,
407 ),
408 [navigation, settingsSheetPresentation.onStackTransitionsFinished],
409 );
410 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
411 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
412 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
413 const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
414 const [submitNavigationAction, setSubmitNavigationAction] = useState<NavigationAction | null>(
415 null,
416 );
417 const [shareImportAttempt, setShareImportAttempt] = useState(0);
418 const startedShareImportKeyRef = useRef<string | null>(null);
419 const cancellingShareImportKeyRef = useRef<string | null>(null);
420 const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>());
421 const activeShareImportTokenRef = useRef<symbol | null>(null);
422 const shareImportMountedRef = useRef(true);
423 const pendingPastedTextAttachmentCountRef = useRef(0);
424 const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0);
425 const pastedTextFileNamesRef = useRef<{ draftKey: string | null; names: Set<string> }>({
426 draftKey: null,
427 names: new Set(),
428 });
429 const latestDraftKeyRef = useRef(flow.draftKey);
430 const latestIncomingShareIdRef = useRef(props.incomingShareId);
431 latestDraftKeyRef.current = flow.draftKey;
432 latestIncomingShareIdRef.current = props.incomingShareId;
433 const isImportingShare = importingShareKey !== null;
434 const alertedUnavailableIncomingShareIdRef = useRef<string | null>(null);
435 // The share this screen already moved into its draft. Sending clears the
436 // draft (and its importedShareIds receipt) a frame before the screen leaves,
437 // and the inbox entry is long gone by then; without this the re-render in
438 // between reads as "shared content vanished" and alerts on every send.
439 const consumedIncomingShareIdRef = useRef<string | null>(null);
440 const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null;
441 const requestedInitialProjectAvailable = Boolean(
442 props.initialProjectRef?.environmentId &&
443 props.initialProjectRef.projectId &&
444 projects.some(
445 (project) =>
446 project.environmentId === props.initialProjectRef?.environmentId &&
447 project.id === props.initialProjectRef?.projectId,
448 ),
449 );
450 const isProjectPickerReturnActive =
451 isReturningToProjectPicker && !requestedInitialProjectAvailable;
452 const isIncomingShareAwaitingServerConfig = Boolean(
453 incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
454 selectedEnvironmentServerConfig === null,
455 );
456 const isIncomingShareTransferPending = Boolean(
457 incomingShare &&
458 cancelledIncomingShareId !== props.incomingShareId &&
459 !isIncomingShareAwaitingServerConfig,
460 );
461 const contextImports = useAtomValue(composerContextImportsAtom);
462 const isImportingContext = flow.draftKey ? contextImports[flow.draftKey] === true : false;
463 const isComposerInteractionLocked =
464 isIncomingShareTransferPending || flow.submitting || isImportingContext;
465 // Hardware keyboard: step to the next machine, from the one a switch in
466 // progress is heading to so repeated presses keep advancing.
467 const { environments, selectedEnvironmentId, switchEnvironment, switchingToEnvironmentId } = flow;
468 const cycleEnvironment = useCallback(() => {
469 if (isComposerInteractionLocked) return true;
470 const next = nextEnvironmentId(environments, switchingToEnvironmentId ?? selectedEnvironmentId);
471 if (next !== null) void switchEnvironment(next);
472 return true;
473 }, [
474 environments,
475 isComposerInteractionLocked,
476 selectedEnvironmentId,
477 switchEnvironment,
478 switchingToEnvironmentId,
479 ]);
480 const cycleEnvironmentCommands = useMemo(
481 () => (environments.length > 1 ? (["cycleHost"] as const) : []),
482 [environments.length],
483 );
484 useHardwareKeyboardCommand(cycleEnvironmentCommands, cycleEnvironment);
485 // Also guard while a submit is in flight: an Android back press or iOS
486 // Cancel would otherwise abandon the screen while the task still starts.
487 // T3 owns /usage-limits only where Limits has data for the selected provider.
488 const offersUsageLimits =
489 flow.selectedProviderStatus !== null &&
490 hasProviderUsageLimits(
491 flow.selectedProviderStatus.driver,
492 selectedEnvironmentServerConfig?.providers ?? [],
493 selectedEnvironmentServerConfig?.usageLimitSources ?? [],
494 );
495 const composerWorkspaceCwd =
496 (flow.workspaceMode === "worktree"
497 ? selectedProject?.workspaceRoot
498 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
499 // Media needs its thumbnail; every other file already reads as its inline chip.
500 const stripAttachments = useMemo(
501 () => composerStripAttachments(flow.attachments),
502 [flow.attachments],
503 );
504 const composerMenu = useComposerCommandMenu({
505 draftMessage: flow.prompt,
506 ownerKey: flow.draftKey,
507 environmentId: selectedProject?.environmentId ?? null,
508 threadShells: useThreadShells(),
509 pullRequestProjectId: selectedEnvironmentServerConfig?.environment.capabilities.pullRequests
510 ? (selectedProject?.id ?? null)
511 : null,
512 pullRequestRepository: selectedProject?.repositoryIdentity?.displayName ?? null,
513 projectCwd: composerWorkspaceCwd,
514 selectedProviderStatus: flow.selectedProviderStatus,
515 hasThread: false,
516 hasCompactableConversation: false,
517 offersUsageLimits: offersUsageLimits,
518 enabled: isComposerFocused && !isComposerInteractionLocked,
519 onChangeDraftMessage: flow.setPrompt,
520 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
521 });
522 const voiceInput = useVoiceInputController({
523 ownerKey: flow.draftKey,
524 label: selectedProjectLabel ? `New task in ${selectedProjectLabel}` : "New task",
525 readDraftMessage: () => (flow.draftKey ? getComposerDraftSnapshot(flow.draftKey).text : null),
526 subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange),
527 selection: composerMenu.selection,
528 disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting,
529 onChangeDraftMessage: flow.setPrompt,
530 onChangeSelection: composerMenu.onSelectionChange,
531 });
532 const voicePresentation = resolveVoiceComposerPresentation(
533 voiceInput.state,
534 voiceInput.elapsedSeconds,
535 );
536 const isVoiceInputPresented = voicePresentation.statusLabel !== null;
537 const preventRemove =
538 (isIncomingShareTransferPending && !isProjectPickerReturnActive) ||
539 isCancellingShareImport ||
540 flow.submitting;
541 usePreventRemove(preventRemove, () => undefined);
542 useEffect(() => {
543 if (preventRemove || submitNavigationAction === null) {
544 return;
545 }
546 // Give the guard update a frame to reach the parent sheet before navigating,
547 // just like the project-picker fallback below.
548 const frame = requestAnimationFrame(() => {
549 setSubmitNavigationAction(null);
550 (navigation.getParent() ?? navigation).dispatch(submitNavigationAction);
551 });
552 return () => cancelAnimationFrame(frame);
553 }, [navigation, preventRemove, submitNavigationAction]);
554 const hasImportedIncomingShare = Boolean(
555 props.incomingShareId &&
556 (consumedIncomingShareIdRef.current === props.incomingShareId ||
557 (flow.draftKey &&
558 getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId))),
559 );
560 const isIncomingShareUnavailable = Boolean(
561 props.incomingShareId &&
562 !isIncomingShareInboxLoading &&
563 !incomingShare &&
564 !hasImportedIncomingShare,
565 );
566 const isIncomingShareReady =
567 !props.incomingShareId ||
568 (hasImportedIncomingShare && !incomingShare) ||
569 isIncomingShareUnavailable;
570 const appliedInitialProjectKeyRef = useRef<string | null>(null);
571 useEffect(() => {
572 if (cancelledIncomingShareId === props.incomingShareId) {
573 navigation.goBack();
574 }
575 }, [cancelledIncomingShareId, navigation, props.incomingShareId]);
576 useEffect(() => {
577 if (!isReturningToProjectPicker) {
578 return;
579 }
580 if (requestedInitialProjectAvailable) {
581 setIsReturningToProjectPicker(false);
582 return;
583 }
584 // Let usePreventRemove commit its disabled state before replacing this
585 // route, otherwise the transfer guard can swallow the fallback action.
586 const frame = requestAnimationFrame(() => {
587 navigation.dispatch(
588 StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }),
589 );
590 });
591 return () => cancelAnimationFrame(frame);
592 }, [
593 isReturningToProjectPicker,
594 navigation,
595 props.incomingShareId,
596 requestedInitialProjectAvailable,
597 ]);
598 useEffect(() => {
599 if (!shareImportMountedRef.current) {
600 startedShareImportKeyRef.current = null;
601 }
602 shareImportMountedRef.current = true;
603 return () => {
604 appliedInitialProjectKeyRef.current = null;
605 shareImportMountedRef.current = false;
606 activeShareImportTokenRef.current = null;
607 cancellingShareImportKeyRef.current = null;
608 };
609 }, []);
610
611 const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow;
612 // A Draft row opens its own draft; a fresh New Task never reuses one.
613 // Drafts hydrate from disk and projects arrive with the shell snapshot, so
614 // on a cold launch the draft or its project can be missing for a moment;
615 // wait for hydration and retry while projects load. Attempt each id once
616 // after that so a draft discarded mid-session does not keep bouncing to
617 // the picker.
618 const attemptedDraftIdRef = useRef<string | null>(null);
619 useEffect(() => {
620 if (!props.draftId || props.pendingTaskId) {
621 return;
622 }
623 const draftId = props.draftId;
624 if (attemptedDraftIdRef.current === draftId) {
625 return;
626 }
627 let cancelled = false;
628 void waitForComposerDraftsLoaded().then(() => {
629 if (cancelled || attemptedDraftIdRef.current === draftId) {
630 return;
631 }
632 if (openDraft(draftId)) {
633 attemptedDraftIdRef.current = draftId;
634 return;
635 }
636 if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) {
637 // The draft exists; its project has not arrived yet. Retry on the
638 // next projects change instead of giving up.
639 return;
640 }
641 attemptedDraftIdRef.current = draftId;
642 navigation.dispatch(StackActions.replace("NewTask"));
643 });
644 return () => {
645 cancelled = true;
646 };
647 }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]);
648
649 const attemptedPendingTaskIdRef = useRef<string | null>(null);
650 useEffect(() => {
651 if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) {
652 return;
653 }
654 // Attempt each pending task once: after it is delivered or deleted the
655 // editing session legitimately ends, and re-running must not navigate.
656 if (attemptedPendingTaskIdRef.current === props.pendingTaskId) {
657 return;
658 }
659 attemptedPendingTaskIdRef.current = props.pendingTaskId;
660 if (!beginEditingPendingTask(props.pendingTaskId)) {
661 // The queued task no longer exists (sent or deleted before opening).
662 navigation.dispatch(StackActions.replace("NewTask"));
663 }
664 }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]);
665
666 useEffect(() => {
667 if (!props.pendingTaskId) return;
668 return () => {
669 // Allow a later navigation for the same pending task to re-hydrate it.
670 attemptedPendingTaskIdRef.current = null;
671 cancelEditingPendingTask();
672 };
673 }, [props.pendingTaskId, cancelEditingPendingTask]);
674
675 const theme = useUniwindTheme();
676 const foregroundColor = theme["--color-foreground"];
677 const regularFontFamily = useFontFamily("regular");
678 const bodyText = useScaledTextRole("body");
679
680 // A new navigation to this mounted screen delivers a fresh initialProjectRef
681 // reference — treat it as a new request and let it apply again.
682 const lastInitialProjectRefRef = useRef(props.initialProjectRef);
683
684 useEffect(() => {
685 // Pending-task editing and draft resumption own project selection (and
686 // must not fall through to the replace("NewTask") fallback while their
687 // hydration is in flight).
688 if (props.pendingTaskId || props.draftId) {
689 return;
690 }
691 if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
692 lastInitialProjectRefRef.current = props.initialProjectRef;
693 appliedInitialProjectKeyRef.current = null;
694 }
695 const initialEnvironmentId = props.initialProjectRef?.environmentId;
696 const initialProjectId = props.initialProjectRef?.projectId;
697 if (initialEnvironmentId && initialProjectId) {
698 const directProject =
699 projects.find(
700 (project) =>
701 project.environmentId === initialEnvironmentId && project.id === initialProjectId,
702 ) ?? null;
703
704 if (directProject) {
705 // Apply the route's project once. Re-applying on every change would
706 // instantly revert environment/project switches made in the picker.
707 const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
708 if (appliedInitialProjectKeyRef.current === directProjectKey) {
709 return;
710 }
711 if (props.initialProjectRef?.branch) {
712 if (
713 selectedProject?.environmentId !== directProject.environmentId ||
714 selectedProject.id !== directProject.id
715 ) {
716 setProject(directProject);
717 return;
718 }
719 if (!flow.draftKey) return;
720 // The route completes checkout before mounting this composer. Local
721 // mode reuses an existing worktree; worktree mode would create another.
722 updateComposerDraftSettings(flow.draftKey, {
723 workspaceSelection: {
724 mode: "local",
725 branch: props.initialProjectRef.branch,
726 worktreePath: props.initialProjectRef.worktreePath ?? null,
727 startFromOrigin: false,
728 },
729 });
730 }
731 appliedInitialProjectKeyRef.current = directProjectKey;
732 if (
733 selectedProject?.environmentId === directProject.environmentId &&
734 selectedProject.id === directProject.id
735 ) {
736 return;
737 }
738 setProject(directProject);
739 return;
740 }
741
742 if (projects.length > 0) {
743 // Never fall through to the flow provider's temporary first-project
744 // default. Return to the picker with the share id intact so the user
745 // can choose an available destination.
746 setIsReturningToProjectPicker(true);
747 }
748 return;
749 }
750
751 const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes);
752 if (selection.kind === "preserve") {
753 return;
754 }
755 if (selection.kind === "select") {
756 setProject(selection.project);
757 return;
758 }
759
760 navigation.dispatch(StackActions.replace("NewTask"));
761 }, [
762 projectScopes,
763 projects,
764 flow.draftKey,
765 props.initialProjectRef,
766 props.incomingShareId,
767 props.pendingTaskId,
768 props.draftId,
769 navigation,
770 selectedProject,
771 selectedProjectKey,
772 setProject,
773 ]);
774
775 useEffect(() => {
776 if (!selectedProject) {
777 loadedBranchesProjectKeyRef.current = null;
778 return;
779 }
780 const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`;
781 if (loadedBranchesProjectKeyRef.current === projectKey) {
782 return;
783 }
784 loadedBranchesProjectKeyRef.current = projectKey;
785 flow.loadBranches();
786 }, [flow.loadBranches, selectedProject]);
787
788 useEffect(() => {
789 const shareId = props.incomingShareId;
790 const draftKey = flow.draftKey;
791 const destinationProject = selectedProject;
792 const initialEnvironmentId = props.initialProjectRef?.environmentId;
793 const initialProjectId = props.initialProjectRef?.projectId;
794 const selectedProjectMatchesRoute =
795 !initialEnvironmentId ||
796 !initialProjectId ||
797 (destinationProject?.environmentId === initialEnvironmentId &&
798 destinationProject.id === initialProjectId);
799 if (
800 !shareId ||
801 !draftKey ||
802 !destinationProject ||
803 !selectedProjectMatchesRoute ||
804 cancelledIncomingShareId === shareId
805 ) {
806 return;
807 }
808 const importKey = `${shareId}:${draftKey}`;
809 if (
810 startedShareImportKeyRef.current === importKey ||
811 cancellingShareImportKeyRef.current === importKey
812 ) {
813 return;
814 }
815
816 if (!incomingShare) {
817 if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) {
818 alertedUnavailableIncomingShareIdRef.current = shareId;
819 Alert.alert(
820 "Shared content unavailable",
821 "The shared content is no longer in the inbox. You can continue editing this task draft.",
822 );
823 }
824 return;
825 }
826
827 if (
828 incomingShare.attachments.some((attachment) => attachment.type === "file") &&
829 selectedEnvironmentServerConfig === null
830 ) {
831 return;
832 }
833
834 if (alertedUnavailableIncomingShareIdRef.current === shareId) {
835 alertedUnavailableIncomingShareIdRef.current = null;
836 }
837 startedShareImportKeyRef.current = importKey;
838 const draftBackup =
839 shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey);
840 shareImportDraftBackupRef.current.set(importKey, draftBackup);
841 const importToken = Symbol(importKey);
842 let didReserveShare = false;
843 let didConsumeShare = false;
844 let needsDraftRestore = false;
845 activeShareImportTokenRef.current = importToken;
846 setImportingShareKey(importKey);
847 void (async () => {
848 await reserveShare(shareId, {
849 environmentId: String(destinationProject.environmentId),
850 projectId: String(destinationProject.id),
851 });
852 didReserveShare = true;
853 if (
854 !shareImportMountedRef.current ||
855 activeShareImportTokenRef.current !== importToken ||
856 latestDraftKeyRef.current !== draftKey ||
857 latestIncomingShareIdRef.current !== shareId
858 ) {
859 return;
860 }
861 const selectedAttachments = selectIncomingShareAttachmentsForServer({
862 attachments: incomingShare.attachments,
863 serverConfig: appAtomRegistry.get(
864 serverEnvironment.configValueAtom(destinationProject.environmentId),
865 ),
866 });
867 if (selectedAttachments.status === "pending") {
868 throw new Error("Server attachment support is still loading.");
869 }
870 needsDraftRestore = true;
871 const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {
872 text: incomingShare.text,
873 attachments: selectedAttachments.attachments,
874 sourceShareId: shareId,
875 });
876 if (
877 !shareImportMountedRef.current ||
878 activeShareImportTokenRef.current !== importToken ||
879 latestDraftKeyRef.current !== draftKey ||
880 latestIncomingShareIdRef.current !== shareId
881 ) {
882 // The durable reservation makes an interrupted transfer resume only
883 // in this project instead of copying into a second project draft.
884 return;
885 }
886 await consumeShare(shareId);
887 didConsumeShare = true;
888 consumedIncomingShareIdRef.current = shareId;
889 // The consumed inbox draft was the last owner of files that never made
890 // it into the composer draft (unsupported server, oversize, limit
891 // skips). Release them before any early return: an unmount or a
892 // superseding import must not leak them, and the sweep re-checks
893 // ownership so it cannot delete a file another draft picked up.
894 const retainedAttachmentIds = new Set(
895 getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id),
896 );
897 scheduleUnusedComposerAttachmentCleanup(
898 incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)),
899 );
900 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
901 return;
902 }
903 const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings];
904 if (skippedAttachmentCount > 0) {
905 warnings.push(
906 `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`,
907 );
908 }
909 if (warnings.length > 0) {
910 Alert.alert("Some shared content was skipped", warnings.join("\n"));
911 }
912 shareImportDraftBackupRef.current.delete(importKey);
913 })()
914 .catch((error) => {
915 if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
916 return;
917 }
918 Alert.alert(
919 "Could not import shared content",
920 error instanceof Error ? error.message : "The shared content could not be saved.",
921 [
922 {
923 text: "Cancel import",
924 style: "cancel",
925 onPress: () => {
926 const cancelImport = async (): Promise<void> => {
927 if (!shareImportMountedRef.current) {
928 return;
929 }
930 // Latch synchronously before restoring the draft. The
931 // restore publishes atom state and can re-run the import
932 // effect before React commits the cancelling state update.
933 cancellingShareImportKeyRef.current = importKey;
934 setIsCancellingShareImport(true);
935 try {
936 if (needsDraftRestore) {
937 // The restore drops the share's merged-in attachments
938 // from the draft. Sweep them only when the inbox entry
939 // was consumed: before that, the inbox still references
940 // these files and must keep them for a later import.
941 const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments;
942 await restoreComposerDraftSnapshot(draftKey, draftBackup);
943 needsDraftRestore = false;
944 if (didConsumeShare) {
945 scheduleUnusedComposerAttachmentCleanup(mergedAttachments);
946 }
947 }
948 if (didReserveShare) {
949 await releaseShareReservation(shareId, {
950 environmentId: String(destinationProject.environmentId),
951 projectId: String(destinationProject.id),
952 });
953 }
954 shareImportDraftBackupRef.current.delete(importKey);
955 if (shareImportMountedRef.current) {
956 setIsCancellingShareImport(false);
957 setCancelledIncomingShareId(shareId);
958 }
959 } catch (cancelError) {
960 if (!shareImportMountedRef.current) {
961 return;
962 }
963 Alert.alert(
964 "Could not cancel import",
965 cancelError instanceof Error
966 ? cancelError.message
967 : "The shared content could not be restored safely.",
968 [
969 {
970 text: "Retry import",
971 onPress: () => {
972 cancellingShareImportKeyRef.current = null;
973 setIsCancellingShareImport(false);
974 setShareImportAttempt((attempt) => attempt + 1);
975 },
976 },
977 {
978 text: "Retry cancel",
979 onPress: () => void cancelImport(),
980 },
981 ],
982 { cancelable: false },
983 );
984 }
985 };
986 void cancelImport();
987 },
988 },
989 {
990 text: "Retry",
991 onPress: () => setShareImportAttempt((attempt) => attempt + 1),
992 },
993 ],
994 { cancelable: false },
995 );
996 })
997 .finally(() => {
998 if (startedShareImportKeyRef.current === importKey) {
999 // Every terminal path, including an invalidated operation, must
1000 // release the synchronous start latch so this transfer can retry.
1001 startedShareImportKeyRef.current = null;
1002 }
1003 if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) {
1004 activeShareImportTokenRef.current = null;
1005 setImportingShareKey(null);
1006 }
1007 });
1008 }, [
1009 consumeShare,
1010 cancelledIncomingShareId,
1011 flow.draftKey,
1012 hasImportedIncomingShare,
1013 incomingShare,
1014 isIncomingShareInboxLoading,
1015 isIncomingShareUnavailable,
1016 props.incomingShareId,
1017 props.initialProjectRef?.environmentId,
1018 props.initialProjectRef?.projectId,
1019 releaseShareReservation,
1020 reserveShare,
1021 selectedEnvironmentServerConfig,
1022 selectedProject,
1023 shareImportAttempt,
1024 ]);
1025
1026 const selectedEnvironmentLabel =
1027 flow.environments.find(
1028 (environment) => environment.environmentId === flow.selectedEnvironmentId,
1029 )?.environmentLabel ?? "Environment";
1030 const availableCurrentBranchName =
1031 flow.availableBranches.find((branch) => branch.current)?.name ??
1032 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
1033 null;
1034 const selectedBranchName = resolveProjectThreadCreationBranch({
1035 workspaceMode: flow.workspaceMode,
1036 selectedBranch:
1037 flow.selectedBranchName ??
1038 (flow.workspaceMode === "worktree" ? availableCurrentBranchName : null),
1039 currentCheckoutBranch: flow.currentCheckoutBranchName,
1040 });
1041 const selectedBranchLabel = resolveNewTaskBranchLabel({
1042 branchName: selectedBranchName,
1043 startFromOrigin: flow.startFromOrigin,
1044 workspaceMode: flow.workspaceMode,
1045 pullRequestReference: flow.pullRequestReference,
1046 });
1047 const workspaceLabel = resolveNewTaskWorkspaceLabel({
1048 workspaceMode: flow.workspaceMode,
1049 worktreePath: flow.selectedWorktreePath,
1050 });
1051 const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
1052
1053 async function handlePickMedia(): Promise<void> {
1054 if (isComposerInteractionLocked || voiceInput.isBusy) {
1055 return;
1056 }
1057 const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
1058 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
1059 const result = await pickComposerMedia({
1060 existingCount:
1061 flow.draftKey && insertion
1062 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
1063 : flow.attachments.length,
1064 maxVideoBytes:
1065 capabilities?.attachmentUploads === true
1066 ? capabilities.fileAttachments?.maxUploadBytes
1067 : undefined,
1068 });
1069 const rejectedCount =
1070 result.attachments.length > 0 ? flow.appendAttachments(result.attachments, insertion) : 0;
1071 const problems = [
1072 ...(result.error ? [result.error] : []),
1073 ...(rejectedCount > 0
1074 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
1075 : []),
1076 ];
1077 if (problems.length > 0) {
1078 Alert.alert("Could not attach photo or video", problems.join("\n\n"));
1079 }
1080 }
1081
1082 async function handlePickFiles(): Promise<void> {
1083 if (isComposerInteractionLocked || voiceInput.isBusy) {
1084 return;
1085 }
1086 const maxBytes =
1087 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes;
1088 if (maxBytes === undefined) {
1089 Alert.alert("File attachments are not available on this server.");
1090 return;
1091 }
1092 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
1093 const result = await pickComposerFiles({
1094 existingCount:
1095 flow.draftKey && insertion
1096 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
1097 : flow.attachments.length,
1098 maxBytes,
1099 });
1100 const rejectedCount =
1101 result.files.length > 0 ? flow.appendAttachments(result.files, insertion) : 0;
1102 // The picker error and the live-cap rejection can both happen in one
1103 // pick; report both in a single alert.
1104 const problems = [
1105 ...(result.error ? [result.error] : []),
1106 ...(rejectedCount > 0
1107 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]
1108 : []),
1109 ];
1110 if (problems.length > 0) {
1111 Alert.alert("Could not attach file", problems.join("\n\n"));
1112 }
1113 }
1114
1115 const handleNativePasteImages = useCallback(
1116 async (uris: ReadonlyArray<string>) => {
1117 try {
1118 const insertion = flow.draftKey ? captureComposerDraftInsertion(flow.draftKey) : undefined;
1119 const images = await convertPastedImagesToAttachments({
1120 uris,
1121 existingCount:
1122 flow.draftKey && insertion
1123 ? countComposerDraftAttachmentsAfterSelection(flow.draftKey, insertion)
1124 : flow.attachments.length,
1125 });
1126 if (images.length > 0) {
1127 flow.appendAttachments(images, insertion);
1128 }
1129 } catch (error) {
1130 console.error("[native paste] error converting images", error);
1131 }
1132 },
1133 [flow],
1134 );
1135
1136 const handleNativePasteText = useCallback(
1137 async (paste: ComposerTextPaste) => {
1138 const draftKey = flow.draftKey;
1139 if (!draftKey) return;
1140 const insertion = { text: paste.value, ...paste.selection };
1141 const insertPaste = () => {
1142 const insertion = replaceTextSelection({
1143 value: paste.value,
1144 selection: paste.selection,
1145 text: paste.text,
1146 });
1147 const selection = { start: insertion.cursor, end: insertion.cursor };
1148 flow.setPrompt(insertion.value);
1149 composerMenu.onSelectionChange(selection);
1150 };
1151 const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
1152 const advertisedMax =
1153 capabilities?.attachmentUploads === true
1154 ? capabilities.fileAttachments?.maxUploadBytes
1155 : undefined;
1156 const maxBytes =
1157 advertisedMax === undefined ? null : clampFileAttachmentUploadBytes(advertisedMax);
1158 const wouldExceedInputLimit =
1159 paste.value.length -
1160 Math.max(0, paste.selection.end - paste.selection.start) +
1161 paste.text.length >
1162 PROVIDER_SEND_TURN_MAX_INPUT_CHARS;
1163 const canAttach =
1164 maxBytes !== null &&
1165 countComposerDraftAttachmentsAfterSelection(draftKey, insertion) <
1166 PROVIDER_SEND_TURN_MAX_ATTACHMENTS &&
1167 new TextEncoder().encode(paste.text).byteLength <= maxBytes;
1168 if (
1169 pastedTextDisposition({
1170 text: paste.text,
1171 wouldExceedInputLimit,
1172 canAttach: true,
1173 }) === "attachment"
1174 ) {
1175 if (canAttach && maxBytes !== null) {
1176 pendingPastedTextAttachmentCountRef.current += 1;
1177 setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current);
1178 try {
1179 if (pastedTextFileNamesRef.current.draftKey !== draftKey) {
1180 pastedTextFileNamesRef.current = { draftKey, names: new Set() };
1181 }
1182 const reservedNames = pastedTextFileNamesRef.current.names;
1183 for (const attachment of flow.attachments) reservedNames.add(attachment.name);
1184 const name = nextPastedTextFileName([...reservedNames]);
1185 reservedNames.add(name);
1186 const attachment = await createPastedTextComposerAttachment({
1187 text: paste.text,
1188 name,
1189 maxBytes,
1190 });
1191 if (latestDraftKeyRef.current !== draftKey) {
1192 await removePersistedComposerAttachmentFile(attachment.fileUri);
1193 return;
1194 }
1195 if (flow.appendAttachments([attachment], insertion) > 0) {
1196 await removePersistedComposerAttachmentFile(attachment.fileUri);
1197 Alert.alert(
1198 "Could not attach pasted text",
1199 `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
1200 );
1201 }
1202 } catch (error) {
1203 Alert.alert(
1204 "Could not attach pasted text",
1205 error instanceof Error ? error.message : "Try again.",
1206 );
1207 } finally {
1208 pendingPastedTextAttachmentCountRef.current = Math.max(
1209 0,
1210 pendingPastedTextAttachmentCountRef.current - 1,
1211 );
1212 setPendingPastedTextAttachmentCount(pendingPastedTextAttachmentCountRef.current);
1213 }
1214 } else if (!wouldExceedInputLimit) {
1215 insertPaste();
1216 } else {
1217 Alert.alert(
1218 wouldExceedInputLimit
1219 ? "Pasted text is too large for this message"
1220 : "Could not attach pasted text",
1221 wouldExceedInputLimit
1222 ? "Remove some text or an attachment, then paste again."
1223 : "Remove an attachment or use a smaller paste, then try again.",
1224 );
1225 }
1226 return;
1227 }
1228
1229 insertPaste();
1230 },
1231 [composerMenu, flow, selectedEnvironmentServerConfig],
1232 );
1233
1234 async function handleStart(): Promise<void> {
1235 if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
1236 const selectedProject = flow.selectedProject;
1237 const draftKey = flow.draftKey;
1238 if (!selectedProject || !draftKey) {
1239 return;
1240 }
1241 if (
1242 environmentConnected &&
1243 !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope)
1244 ) {
1245 return;
1246 }
1247 const draft = getComposerDraftSnapshot(draftKey);
1248 if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return;
1249 // Read the latest explicit pick. Antigravity selections stay unchanged
1250 // when setup or a catalog change makes them unavailable.
1251 const modelSelection =
1252 resolveSelectableModelSelection(
1253 selectedEnvironmentServerConfig,
1254 draft.modelSelection ?? null,
1255 selectedProject.id,
1256 ) ?? flow.selectedModel;
1257 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
1258 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
1259 const initialMessageText = draft.text.trim();
1260
1261 if (
1262 attachmentBlockReason !== null ||
1263 !modelSelection ||
1264 (initialMessageText.length === 0 && draft.attachments.length === 0) ||
1265 flow.submitting ||
1266 (workspaceMode === "worktree" && !selectedBranchName)
1267 ) {
1268 return;
1269 }
1270 if (
1271 environmentConnected &&
1272 isModelSelectionUnavailable(
1273 selectedEnvironmentServerConfig,
1274 modelSelection,
1275 selectedProject.id,
1276 )
1277 ) {
1278 const { title, detail } = describeUnavailableModelSelection(
1279 selectedEnvironmentServerConfig,
1280 modelSelection,
1281 selectedProject.id,
1282 );
1283 Alert.alert(title, detail);
1284 return;
1285 }
1286 // T3's own limits command is answered by the thread composer; a new task would
1287 // send it to the agent. A provider's same-named command, or a prompt carrying
1288 // attachments, goes through as usual.
1289 if (
1290 offersUsageLimits &&
1291 isUsageLimitsCommand(initialMessageText) &&
1292 draft.attachments.length === 0
1293 ) {
1294 Alert.alert(
1295 "Usage limits",
1296 "Send /usage-limits inside a thread, or open Settings → Usage → Limits.",
1297 );
1298 return;
1299 }
1300 // A failed-send restore can leave the draft over the cap on purpose (it
1301 // never drops the user's files); starting anyway would upload everything
1302 // and have the server reject the turn.
1303 if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
1304 Alert.alert(
1305 "Too many attachments",
1306 `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
1307 );
1308 return;
1309 }
1310
1311 const contextBlockReason = composerContextSendBlockReason(draft.context);
1312 if (contextBlockReason) {
1313 Alert.alert("Too much context", contextBlockReason);
1314 return;
1315 }
1316
1317 const editingPendingTask = flow.editingPendingTask;
1318
1319 // Every submission goes through the outbox: the drain uploads the
1320 // attachments and delivers the creation, retrying across reconnects.
1321 // When it can send now the thread screen opens immediately with the
1322 // queued prompt and reports setup progress there, like the web draft
1323 // does. Offline, or with uploads still in flight, the task stays a
1324 // pending task and the sheet closes. Editing an existing pending task
1325 // re-queues it under its original identifiers.
1326 const metadata = editingPendingTask
1327 ? {
1328 threadId: editingPendingTask.threadId,
1329 commandId: editingPendingTask.commandId,
1330 messageId: editingPendingTask.messageId,
1331 createdAt: editingPendingTask.createdAt,
1332 }
1333 : makeTurnCommandMetadata();
1334 const message = flow.buildPendingTaskMessage(metadata, {
1335 // A task that waits in the outbox cannot know the checkout it will
1336 // drain against; one that sends now runs against the live one.
1337 currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
1338 });
1339 if (!message) {
1340 return;
1341 }
1342 if (!queuesInsteadOfStarting) {
1343 // Arm the lock-screen card before the async thread creation: backgrounding
1344 // the app right after tapping submit would otherwise reject the foreground
1345 // -only Activity start. If creation fails, the token registration's replay
1346 // finds no work and ends the card within seconds.
1347 armAgentAwarenessLiveActivityForLocalWork({
1348 environmentId: selectedProject.environmentId,
1349 threadTitle: deriveThreadTitleSeed({
1350 text: initialMessageText,
1351 attachments: draft.attachments,
1352 }),
1353 projectTitle: selectedProject.title,
1354 });
1355 }
1356 // Persist before clearing the draft or leaving its editor. This only waits
1357 // for the local outbox write; server and worktree setup run on the thread.
1358 flow.setSubmitting(true);
1359 try {
1360 await enqueueThreadOutboxMessage(message);
1361 } catch (error) {
1362 Alert.alert(
1363 "Could not queue task",
1364 error instanceof Error ? error.message : "The task could not be saved to the outbox.",
1365 );
1366 return;
1367 } finally {
1368 flow.setSubmitting(false);
1369 }
1370 const draftSnapshot = getComposerDraftSnapshot(draftKey);
1371 if (editingPendingTask) {
1372 flow.finishEditingPendingTask();
1373 } else {
1374 // Drop draft-local model/workspace selections with the content. The
1375 // next task re-resolves project defaults before sticky app defaults.
1376 // The queued message owns the attachments now, so the sweep is deferred
1377 // until the write confirms it.
1378 clearComposerDraftContent(draftKey, {
1379 clearModelSelection: true,
1380 clearWorkspaceSelection: true,
1381 deferAttachmentCleanup: true,
1382 });
1383 }
1384 setSubmitNavigationAction(
1385 queuesInsteadOfStarting
1386 ? CommonActions.goBack()
1387 : StackActions.replace("Thread", {
1388 environmentId: String(message.environmentId),
1389 threadId: String(message.threadId),
1390 }),
1391 );
1392 scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
1393 }
1394
1395 if (!selectedProject) {
1396 return (
1397 <View className="flex-1 bg-sheet" collapsable={false}>
1398 {Platform.OS === "android" ? (
1399 <>
1400 <NativeStackScreenOptions options={{ headerShown: false }} />
1401 <AndroidScreenHeader
1402 title="New thread"
1403 hideBottomBorder
1404 onBack={() => navigation.goBack()}
1405 />
1406 </>
1407 ) : (
1408 <NativeStackScreenOptions options={{ title: "Loading task" }} />
1409 )}
1410 </View>
1411 );
1412 }
1413
1414 const isAndroid = Platform.OS === "android";
1415 const canStart =
1416 !isImportingContext &&
1417 !cloneBlocksStart &&
1418 taskPermissionReason === null &&
1419 attachmentBlockReason === null &&
1420 !modelUnavailable &&
1421 Boolean(flow.selectedProject) &&
1422 Boolean(flow.selectedModel) &&
1423 (flow.prompt.trim().length > 0 || flow.attachments.length > 0) &&
1424 isIncomingShareReady &&
1425 !isImportingShare &&
1426 !flow.submitting &&
1427 pendingPastedTextAttachmentCount === 0 &&
1428 !voiceInput.blocksSubmission &&
1429 !(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
1430 const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
1431 // A draft attachment lives only in the draft. Without its key the screen would fall through
1432 // to a remote lookup for bytes the server has never seen.
1433 const draftKey = flow.draftKey;
1434 if (!draftKey) return;
1435 promptInputRef.current?.blur();
1436 void KeyboardController.dismiss({ animated: true });
1437 navigation.dispatch(
1438 StackActions.push("NewTaskAttachment", {
1439 environmentId: String(selectedProject.environmentId),
1440 attachmentId: attachment.attachmentId,
1441 name: attachment.name,
1442 mimeType: attachment.mimeType,
1443 sizeBytes: String(attachment.sizeBytes),
1444 draftKey,
1445 }),
1446 );
1447 };
1448 const promptEditor = (
1449 <>
1450 <ComposerEditor
1451 draftKey={flow.draftKey}
1452 environmentId={selectedProject.environmentId}
1453 onOpenAttachment={openDraftDocument}
1454 onOpenMention={(path) => {
1455 if (!composerWorkspaceCwd) return;
1456 promptInputRef.current?.blur();
1457 void KeyboardController.dismiss({ animated: true });
1458 navigation.dispatch(
1459 StackActions.push("NewTaskFile", {
1460 environmentId: String(selectedProject.environmentId),
1461 cwd: composerWorkspaceCwd,
1462 projectName: selectedProject.title,
1463 path: fileRoutePathSegments(path),
1464 }),
1465 );
1466 }}
1467 ref={promptInputRef}
1468 // The context-first screen intentionally opens with the keyboard closed.
1469 // Focusing is a user action, so presenting the form sheet has one motion.
1470 autoFocus={false}
1471 // Clipboard imports use the editor's read-only mode to retain keyboard focus.
1472 editable={!isIncomingShareTransferPending && !flow.submitting}
1473 readOnly={voiceInput.freezesEditor}
1474 multiline
1475 scrollEnabled
1476 value={flow.prompt}
1477 skills={composerMenu.skills}
1478 selection={composerMenu.selection}
1479 onChangeText={flow.setPrompt}
1480 onSubmit={() => {
1481 if (canStart) void handleStart();
1482 }}
1483 onSelectionChange={composerMenu.onSelectionChange}
1484 onFocus={() => setIsComposerFocused(true)}
1485 onBlur={() => setIsComposerFocused(false)}
1486 onPasteImages={(uris) => void handleNativePasteImages(uris)}
1487 onPasteText={(paste) => void handleNativePasteText(paste)}
1488 placeholder="Ask anything…"
1489 singleLineCentered={false}
1490 contentInsetVertical={0}
1491 style={{
1492 minHeight: 72,
1493 maxHeight: 160,
1494 paddingVertical: 4,
1495 }}
1496 textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
1497 />
1498 </>
1499 );
1500
1501 const closeNewTask = () => {
1502 void KeyboardController.dismiss({ animated: true });
1503 const parentNavigation = navigation.getParent();
1504 if (parentNavigation) {
1505 parentNavigation.goBack();
1506 return;
1507 }
1508 navigation.goBack();
1509 };
1510 const chooseProject = () => {
1511 if (isComposerInteractionLocked) {
1512 return;
1513 }
1514 promptInputRef.current?.blur();
1515 void KeyboardController.dismiss({ animated: true });
1516 navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
1517 };
1518 const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
1519 if (isComposerInteractionLocked) {
1520 return;
1521 }
1522 promptInputRef.current?.blur();
1523 void KeyboardController.dismiss({ animated: true });
1524 navigation.dispatch(StackActions.push(routeName));
1525 };
1526
1527 const environmentControl = (
1528 <ComposerInlineControl
1529 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
1530 chevronDirection="right"
1531 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1532 renderIcon={(size) => (
1533 <EnvironmentMachineSymbol
1534 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1535 size={size}
1536 tintColorClassName="accent-icon-muted"
1537 />
1538 )}
1539 label={`on ${selectedEnvironmentLabel}`}
1540 maxWidth={flow.isScratchDraft ? 170 : 260}
1541 onPress={
1542 flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
1543 }
1544 showChevron={flow.environments.length > 1}
1545 static={flow.environments.length <= 1}
1546 />
1547 );
1548 // A thread without a project has no project to name, so it asks plainly,
1549 // like web, and puts the project picker beside the machine as a control.
1550 const hero = flow.isScratchDraft ? (
1551 <View className="items-center gap-2 px-6" testID="new-task-hero">
1552 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
1553 What should we work on?
1554 </Text>
1555 {/* Wraps onto two lines only when a long machine name leaves no room. */}
1556 <View className="flex-row flex-wrap items-center justify-center gap-x-1">
1557 <ComposerInlineControl
1558 accessibilityHint="Opens the project picker"
1559 accessibilityLabel="Choose a project"
1560 chevronDirection="right"
1561 disabled={isComposerInteractionLocked}
1562 icon="folder"
1563 label="Choose a project"
1564 onPress={chooseProject}
1565 />
1566 {environmentControl}
1567 </View>
1568 </View>
1569 ) : (
1570 <View className="items-center gap-6 px-6" testID="new-task-hero">
1571 <View className="w-full items-center gap-1.5">
1572 <Text className="text-center text-2xl font-t3-medium tracking-tight text-foreground">
1573 What should we build
1574 </Text>
1575 <View className="max-w-full flex-row items-center justify-center">
1576 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">in </Text>
1577 <Pressable
1578 accessibilityHint="Opens the project picker"
1579 accessibilityLabel={selectedProjectLabel ?? selectedProject.title}
1580 accessibilityRole="button"
1581 disabled={isComposerInteractionLocked}
1582 onPress={chooseProject}
1583 className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"
1584 >
1585 <Text
1586 className="text-2xl font-t3-medium tracking-tight text-foreground"
1587 numberOfLines={1}
1588 >
1589 {selectedProjectLabel ?? selectedProject.title}
1590 </Text>
1591 </Pressable>
1592 <Text className="text-2xl font-t3-medium tracking-tight text-foreground">?</Text>
1593 </View>
1594 </View>
1595
1596 {environmentControl}
1597 </View>
1598 );
1599 const heroViewport = (
1600 <View className="flex-1" collapsable={false}>
1601 <ScrollView
1602 alwaysBounceVertical={isKeyboardVisible}
1603 className="flex-1"
1604 contentInsetAdjustmentBehavior="never"
1605 contentContainerClassName="grow items-center pb-[236px] pt-12 ios:pt-[72px]"
1606 keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
1607 keyboardShouldPersistTaps="handled"
1608 showsVerticalScrollIndicator={false}
1609 style={{ flex: 1 }}
1610 testID="new-task-hero-scroll"
1611 >
1612 {hero}
1613 </ScrollView>
1614 </View>
1615 );
1616
1617 const workspaceControls = (
1618 <View className="flex-row items-center gap-1 px-2">
1619 <ComposerInlineControl
1620 accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`}
1621 accessibilityLabel={workspaceLabel}
1622 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1623 renderIcon={(size) => (
1624 <NewTaskWorkspaceIcon
1625 workspaceMode={flow.workspaceMode}
1626 worktreePath={flow.selectedWorktreePath}
1627 size={size}
1628 />
1629 )}
1630 label={workspaceLabel}
1631 maxWidth={flow.workspaceMode === "local" ? 220 : 148}
1632 onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
1633 showChevron={false}
1634 />
1635
1636 <ComposerInlineControl
1637 accessibilityLabel={`${flow.workspaceMode === "worktree" ? "Base branch" : "Branch"}: ${selectedBranchLabel}`}
1638 chevronDirection="right"
1639 disabled={isComposerInteractionLocked}
1640 icon="arrow.triangle.branch"
1641 label={showBranchLoading ? "Loading branches…" : selectedBranchLabel}
1642 maxWidth={190}
1643 onPress={() => openContextPicker("NewTaskBranch")}
1644 />
1645 </View>
1646 );
1647
1648 const composerDock = (
1649 <View
1650 className={
1651 Platform.OS === "android" ? "bg-sheet-solid px-[12px] pt-1" : "bg-sheet px-[12px] pt-1"
1652 }
1653 style={{ paddingBottom: controlsBottomPadding }}
1654 >
1655 {!voiceInput.isBusy &&
1656 composerMenu.trigger &&
1657 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
1658 <View className="mb-2">
1659 <ComposerCommandPopover
1660 items={composerMenu.items}
1661 triggerKind={composerMenu.trigger.kind}
1662 isLoading={composerMenu.isLoading}
1663 error={composerMenu.error}
1664 onSelect={composerMenu.onSelect}
1665 />
1666 </View>
1667 ) : null}
1668 {/* Above the workspace controls so they keep their place relative to
1669 the composer when the banner goes away once the clone lands. */}
1670 {projectClone && projectClone.phase !== "done" && selectedProject ? (
1671 <View className="px-1 pb-2">
1672 <ProjectCloneBanner
1673 clone={projectClone}
1674 onCancel={() =>
1675 void runCloneAction("Failed to cancel clone", () =>
1676 cancelProjectClone({
1677 environmentId: selectedProject.environmentId,
1678 input: { projectId: selectedProject.id },
1679 }),
1680 )
1681 }
1682 onRetry={() =>
1683 void runCloneAction("Failed to retry clone", () =>
1684 retryProjectClone({
1685 environmentId: selectedProject.environmentId,
1686 input: { projectId: selectedProject.id },
1687 }),
1688 )
1689 }
1690 onRemove={() => void removeClonedProject()}
1691 />
1692 </View>
1693 ) : null}
1694 {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null}
1695
1696 {taskPermissionReason ? (
1697 <Text className="px-3 py-2 text-xs text-muted-foreground">{taskPermissionReason}</Text>
1698 ) : null}
1699
1700 {modelUnavailable ? (
1701 <Pressable
1702 accessibilityRole="button"
1703 className="px-3 py-2"
1704 disabled={isComposerInteractionLocked}
1705 onPress={settingsSheetPresentation.open}
1706 >
1707 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
1708 </Pressable>
1709 ) : null}
1710
1711 <ComposerSurface
1712 style={{
1713 borderRadius: 26,
1714 minHeight: 140,
1715 overflow: "hidden",
1716 paddingBottom: 6,
1717 paddingTop: 14,
1718 }}
1719 >
1720 {stripAttachments.length > 0 ? (
1721 <View className="px-[14px] pb-2.5">
1722 <ComposerAttachmentStrip
1723 environmentId={selectedProject.environmentId}
1724 attachments={stripAttachments}
1725 imageBorderRadius={16}
1726 imageSize={72}
1727 onRemove={
1728 isComposerInteractionLocked || voiceInput.isBusy
1729 ? () => undefined
1730 : flow.removeAttachment
1731 }
1732 onPressPreview={
1733 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
1734 }
1735 onPressVideo={
1736 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
1737 }
1738 onPressDocument={
1739 isComposerInteractionLocked || voiceInput.isBusy
1740 ? undefined
1741 : (attachment) =>
1742 openDraftDocument({
1743 attachmentId: attachment.id,
1744 name: attachment.name,
1745 mimeType: attachment.mimeType,
1746 sizeBytes: attachment.sizeBytes,
1747 })
1748 }
1749 />
1750 </View>
1751 ) : null}
1752
1753 <View className="px-[14px]">{promptEditor}</View>
1754 <View className="h-1" />
1755
1756 <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} collapsable={false}>
1757 <ComposerDictationToolbar showsDictation={isVoiceInputPresented}>
1758 <ComposerToolbarRow
1759 paddingBottom={0}
1760 paddingHorizontal={0}
1761 paddingTop={0}
1762 style={{ gap: 0 }}
1763 >
1764 <ComposerDictationCancelAction
1765 presentation={voicePresentation}
1766 onCancel={voiceInput.cancel}
1767 />
1768 {isVoiceInputPresented ? (
1769 <ComposerDictationStatus
1770 audioLevels={voiceInput.audioLevels}
1771 elapsedSeconds={voiceInput.elapsedSeconds}
1772 phase={voiceInput.state.phase}
1773 presentation={voicePresentation}
1774 onDismissError={voiceInput.cancel}
1775 />
1776 ) : (
1777 <>
1778 <ComposerAttachmentButton
1779 disabled={isComposerInteractionLocked}
1780 supportsFiles={Boolean(
1781 selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments,
1782 )}
1783 onPickMedia={handlePickMedia}
1784 onPickFiles={handlePickFiles}
1785 />
1786 <View className="min-w-0 flex-1 flex-row items-center justify-end gap-2">
1787 <View className="min-w-0 shrink">
1788 <ComposerInlineControl
1789 accessibilityLabel="Model and reasoning settings"
1790 disabled={isComposerInteractionLocked}
1791 emphasized
1792 renderIcon={(size) => (
1793 <ProviderIcon
1794 iconUrl={flow.selectedModelOption?.providerIconUrl}
1795 provider={flow.selectedModelOption?.providerDriver}
1796 size={size}
1797 />
1798 )}
1799 label={flow.selectedModelOption?.label ?? "Choose model"}
1800 maxWidth="100%"
1801 onPress={settingsSheetPresentation.open}
1802 />
1803 </View>
1804 {flow.planModeEnabled ? (
1805 <ComposerInlineControl
1806 accessibilityHint={`Switches to ${flow.interactionMode === "plan" ? "Build" : "Plan"} mode`}
1807 accessibilityLabel={`Interaction mode: ${flow.interactionMode === "plan" ? "Plan" : "Build"}`}
1808 disabled={isComposerInteractionLocked}
1809 emphasized
1810 icon={
1811 flow.interactionMode === "plan"
1812 ? { ios: "list.bullet.clipboard", android: "auto_awesome" }
1813 : { ios: "hammer", android: "construction" }
1814 }
1815 label={flow.interactionMode === "plan" ? "Plan" : "Build"}
1816 onPress={() =>
1817 flow.setInteractionMode(
1818 flow.interactionMode === "plan" ? "default" : "plan",
1819 )
1820 }
1821 showChevron={false}
1822 />
1823 ) : null}
1824 </View>
1825 </>
1826 )}
1827 <ComposerDictationPrimaryAction
1828 state={voiceInput.state}
1829 presentation={voicePresentation}
1830 isAvailable={voiceInput.isAvailable}
1831 disabled={isIncomingShareTransferPending || isImportingShare || flow.submitting}
1832 onStart={voiceInput.start}
1833 onConfirm={voiceInput.stop}
1834 onCancel={voiceInput.cancel}
1835 />
1836 {voicePresentation.showsSend ? (
1837 <ComposerActionButton
1838 accessibilityLabel={
1839 taskPermissionReason ??
1840 attachmentBlockReason ??
1841 (cloneBlocksStart
1842 ? projectClone === null || projectClone.phase === "running"
1843 ? "Cloning repository"
1844 : "Repository not cloned"
1845 : pendingPastedTextAttachmentCount > 0
1846 ? "Attaching pasted text"
1847 : flow.submitting
1848 ? "Starting task"
1849 : attachmentsUploading
1850 ? "Queue task, sends when uploads finish"
1851 : environmentConnected
1852 ? "Start task"
1853 : "Queue task")
1854 }
1855 disabled={!canStart}
1856 icon={queuesInsteadOfStarting ? "tray.and.arrow.up" : "arrow.up"}
1857 onPress={() => void handleStart()}
1858 variant="primary"
1859 />
1860 ) : null}
1861 </ComposerToolbarRow>
1862 </ComposerDictationToolbar>
1863 </Animated.View>
1864 </ComposerSurface>
1865 <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} />
1866 <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} />
1867 </View>
1868 );
1869
1870 if (isAndroid) {
1871 return (
1872 <View className="flex-1 bg-sheet" collapsable={false}>
1873 <NativeStackScreenOptions options={{ headerShown: false }} />
1874 <AndroidScreenHeader title="New thread" hideBottomBorder onBack={closeNewTask} />
1875 <MaterialScreenContent>
1876 {heroViewport}
1877
1878 <KeyboardStickyView
1879 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1880 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1881 >
1882 {composerDock}
1883 </KeyboardStickyView>
1884 </MaterialScreenContent>
1885 </View>
1886 );
1887 }
1888
1889 return (
1890 <View className="flex-1 bg-sheet" collapsable={false}>
1891 <NativeStackScreenOptions
1892 options={{
1893 headerBackVisible: false,
1894 headerShadowVisible: false,
1895 title: "New thread",
1896 }}
1897 />
1898 <NativeHeaderToolbar placement="left">
1899 <NativeHeaderToolbar.Button
1900 accessibilityLabel="Cancel new task"
1901 label="Cancel"
1902 onPress={closeNewTask}
1903 />
1904 </NativeHeaderToolbar>
1905
1906 {heroViewport}
1907 <KeyboardStickyView
1908 pointerEvents="box-none"
1909 style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}
1910 offset={{ closed: 0, opened: keyboardOpenedOffset }}
1911 >
1912 <Animated.View
1913 layout={COMPOSER_LAYOUT_TRANSITION}
1914 pointerEvents="box-none"
1915 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1916 >
1917 {composerDock}
1918 </Animated.View>
1919 </KeyboardStickyView>
1920 </View>
1921 );
1922}