import { useAtomValue } from "@effect/atom-react";import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";import { CommonActions, StackActions, useFocusEffect, useNavigation, usePreventRemove, type NavigationAction,} from "@react-navigation/native";import { useCallback, useEffect, useRef, useState } from "react";import { Alert, Platform, Pressable, ScrollView, View } from "react-native";import { KeyboardController, KeyboardStickyView, useKeyboardState,} from "react-native-keyboard-controller";import Animated from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { useFontFamily } from "../../lib/useFontFamily"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind,} from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow,} from "../../components/ComposerToolbar";import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, composerAttachmentUploadsAtom,} from "../../state/composer-attachment-uploads";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { ProviderIcon } from "../../components/ProviderIcon";import { SymbolView } from "../../components/AppSymbol";import { AppText as Text } from "../../components/AppText";import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";import { ComposerCommandPopover } from "./ComposerCommandPopover";import { useComposerCommandMenu } from "./use-composer-command-menu";import { ComposerDictationCancelAction, ComposerDictationPrimaryAction, ComposerDictationStatus, ComposerDictationToolbar,} from "../voice-input/ComposerDictationControl";import { useVoiceInputController } from "../voice-input/useVoiceInputController";import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning,} from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata";import { convertPastedImagesToAttachments, pickComposerFiles, pickComposerMedia, type DraftComposerFileAttachment,} from "../../lib/composerImages";import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";import { clearComposerDraftContent, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, waitForComposerDraftsLoaded,} from "../../state/use-composer-drafts";import { useEnvironmentServerConfig, useProjects } from "../../state/entities";import { isModelSelectionUnavailable, resolveSelectableModelSelection,} from "../../lib/modelOptions";import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";import { useNewTaskFlow } from "./new-task-flow-provider";import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";import { resolveDraftProjectSelection } from "./new-task-project-selection";import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel,} from "./new-task-context-presentation";import { useIncomingShare } from "../sharing/IncomingShareProvider";import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model";import { appAtomRegistry } from "../../state/atom-registry";import { serverEnvironment } from "../../state/server"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null;}) { if (props.workspaceMode === "local" && props.worktreePath === null) { return ( <SymbolView name="folder" size={16} tintColorClassName={"accent-icon-muted"} type="monochrome" /> ); } return ( <View className="size-4"> <SymbolView name="folder" size={16} tintColorClassName={"accent-icon-muted"} type="monochrome" /> <View className="absolute -right-1 -bottom-1"> <SymbolView name="arrow.triangle.branch" size={9} tintColorClassName={"accent-icon-muted"} type="monochrome" /> </View> </View> );} export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; readonly projectId?: string; readonly branch?: string | null; readonly worktreePath?: string | null; }; /** Queued outbox message id when editing an existing pending task. */ readonly pendingTaskId?: string; /** Existing new-task draft key to resume (a Draft row in the thread list). */ readonly draftId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string;}) { const projects = useProjects(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { consumeShare, getShare, isLoading: isIncomingShareInboxLoading, releaseShareReservation, reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentBlockReason = selectedProject ? composerAttachmentUploadBlockReason({ environmentId: selectedProject.environmentId, attachments: flow.attachments, connected: environmentConnected, serverConfig: selectedEnvironmentServerConfig, states: uploadStates, }) : null; // A connected composer with uploads still in flight queues the task rather // than making the user wait: the outbox drain finishes the upload and sends. const attachmentsUploading = environmentConnected && selectedProject !== null && composerAttachmentsStillUploading({ environmentId: selectedProject.environmentId, attachments: flow.attachments, serverConfig: selectedEnvironmentServerConfig, states: uploadStates, }); const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading; const promptInputRef = useRef<ComposerEditorHandle>(null); const loadedBranchesProjectKeyRef = useRef<string | null>(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); const wasFocusedBeforePreviewRef = useRef(false); const openVideoPreview = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasFocusedBeforePreviewRef.current = isComposerFocused; setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isComposerFocused], ); const openFilePreview = useCallback( (source: FilePreviewSource) => { wasFocusedBeforePreviewRef.current = isComposerFocused; setPreviewVideo(null); setPreviewFile((current) => current ?? source); }, [isComposerFocused], ); const closeMediaPreview = useCallback(() => { setPreviewVideo(null); setPreviewFile(null); if (wasFocusedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) promptInputRef.current?.focus(); }, 100); } }, [navigation]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, }); useEffect(() => { if (Platform.OS !== "ios") { return; } navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible }); }, [isKeyboardVisible, navigation]); useEffect(() => { return () => { if (Platform.OS === "ios") { navigation.getParent()?.setOptions({ gestureEnabled: true }); } }; }, [navigation]); const settingsRoutePresentedRef = useRef(false); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = true; navigation.dispatch(StackActions.push("ThreadSettings")); }, [navigation, settingsSheetPresentation.isVisible]); useFocusEffect( useCallback(() => { if (!settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = false; settingsSheetPresentation.onDismissed(); }, [settingsSheetPresentation.onDismissed]), ); useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", settingsSheetPresentation.onStackTransitionsFinished, ), [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); const [importingShareKey, setImportingShareKey] = useState<string | null>(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); const [submitNavigationAction, setSubmitNavigationAction] = useState<NavigationAction | null>( null, ); const [shareImportAttempt, setShareImportAttempt] = useState(0); const startedShareImportKeyRef = useRef<string | null>(null); const cancellingShareImportKeyRef = useRef<string | null>(null); const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>()); const activeShareImportTokenRef = useRef<symbol | null>(null); const shareImportMountedRef = useRef(true); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; const isImportingShare = importingShareKey !== null; const alertedUnavailableIncomingShareIdRef = useRef<string | null>(null); const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; const requestedInitialProjectAvailable = Boolean( props.initialProjectRef?.environmentId && props.initialProjectRef.projectId && projects.some( (project) => project.environmentId === props.initialProjectRef?.environmentId && project.id === props.initialProjectRef?.projectId, ), ); const isProjectPickerReturnActive = isReturningToProjectPicker && !requestedInitialProjectAvailable; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, ); const isIncomingShareTransferPending = Boolean( incomingShare && cancelledIncomingShareId !== props.incomingShareId && !isIncomingShareAwaitingServerConfig, ); const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. // T3 owns /usage-limits only where Limits has data for the selected provider. const offersUsageLimits = flow.selectedProviderStatus !== null && hasProviderUsageLimits( flow.selectedProviderStatus.driver, selectedEnvironmentServerConfig?.providers ?? [], selectedEnvironmentServerConfig?.usageLimitSources ?? [], ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, environmentId: selectedProject?.environmentId ?? null, projectCwd: (flow.workspaceMode === "worktree" ? selectedProject?.workspaceRoot : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: flow.draftKey, draftMessage: flow.prompt, selection: composerMenu.selection, disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting, onChangeDraftMessage: flow.setPrompt, onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( voiceInput.state, voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; const preventRemove = (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport || flow.submitting; usePreventRemove(preventRemove, () => undefined); useEffect(() => { if (preventRemove || submitNavigationAction === null) { return; } // Give the guard update a frame to reach the parent sheet before navigating, // just like the project-picker fallback below. const frame = requestAnimationFrame(() => { setSubmitNavigationAction(null); (navigation.getParent() ?? navigation).dispatch(submitNavigationAction); }); return () => cancelAnimationFrame(frame); }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && flow.draftKey && getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), ); const isIncomingShareUnavailable = Boolean( props.incomingShareId && !isIncomingShareInboxLoading && !incomingShare && !hasImportedIncomingShare, ); const isIncomingShareReady = !props.incomingShareId || (hasImportedIncomingShare && !incomingShare) || isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef<string | null>(null); useEffect(() => { if (cancelledIncomingShareId === props.incomingShareId) { navigation.goBack(); } }, [cancelledIncomingShareId, navigation, props.incomingShareId]); useEffect(() => { if (!isReturningToProjectPicker) { return; } if (requestedInitialProjectAvailable) { setIsReturningToProjectPicker(false); return; } // Let usePreventRemove commit its disabled state before replacing this // route, otherwise the transfer guard can swallow the fallback action. const frame = requestAnimationFrame(() => { navigation.dispatch( StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), ); }); return () => cancelAnimationFrame(frame); }, [ isReturningToProjectPicker, navigation, props.incomingShareId, requestedInitialProjectAvailable, ]); useEffect(() => { if (!shareImportMountedRef.current) { startedShareImportKeyRef.current = null; } shareImportMountedRef.current = true; return () => { appliedInitialProjectKeyRef.current = null; shareImportMountedRef.current = false; activeShareImportTokenRef.current = null; cancellingShareImportKeyRef.current = null; }; }, []); const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; // A Draft row opens its own draft; a fresh New Task never reuses one. // Drafts hydrate from disk and projects arrive with the shell snapshot, so // on a cold launch the draft or its project can be missing for a moment; // wait for hydration and retry while projects load. Attempt each id once // after that so a draft discarded mid-session does not keep bouncing to // the picker. const attemptedDraftIdRef = useRef<string | null>(null); useEffect(() => { if (!props.draftId || props.pendingTaskId) { return; } const draftId = props.draftId; if (attemptedDraftIdRef.current === draftId) { return; } let cancelled = false; void waitForComposerDraftsLoaded().then(() => { if (cancelled || attemptedDraftIdRef.current === draftId) { return; } if (openDraft(draftId)) { attemptedDraftIdRef.current = draftId; return; } if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { // The draft exists; its project has not arrived yet. Retry on the // next projects change instead of giving up. return; } attemptedDraftIdRef.current = draftId; navigation.dispatch(StackActions.replace("NewTask")); }); return () => { cancelled = true; }; }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); const attemptedPendingTaskIdRef = useRef<string | null>(null); useEffect(() => { if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { return; } // Attempt each pending task once: after it is delivered or deleted the // editing session legitimately ends, and re-running must not navigate. if (attemptedPendingTaskIdRef.current === props.pendingTaskId) { return; } attemptedPendingTaskIdRef.current = props.pendingTaskId; if (!beginEditingPendingTask(props.pendingTaskId)) { // The queued task no longer exists (sent or deleted before opening). navigation.dispatch(StackActions.replace("NewTask")); } }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]); useEffect(() => { if (!props.pendingTaskId) return; return () => { // Allow a later navigation for the same pending task to re-hydrate it. attemptedPendingTaskIdRef.current = null; cancelEditingPendingTask(); }; }, [props.pendingTaskId, cancelEditingPendingTask]); const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); // A new navigation to this mounted screen delivers a fresh initialProjectRefLines 1–500 of 1473. Load more
import { useAtomValue } from "@effect/atom-react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { CommonActions, StackActions, useFocusEffect, useNavigation, usePreventRemove, type NavigationAction, } from "@react-navigation/native"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { KeyboardController, KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow, } from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, composerAttachmentUploadsAtom, } from "../../state/composer-attachment-uploads"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, ComposerDictationPrimaryAction, ComposerDictationStatus, ComposerDictationToolbar, } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerFiles, pickComposerMedia, type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, updateComposerDraftSettings, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { isModelSelectionUnavailable, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { resolveNewTaskBranchLabel, resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model"; import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null; }) { if (props.workspaceMode === "local" && props.worktreePath === null) { return ( <SymbolView name="folder" size={16} tintColorClassName={"accent-icon-muted"} type="monochrome" /> ); } return ( <View className="size-4"> <SymbolView name="folder" size={16} tintColorClassName={"accent-icon-muted"} type="monochrome" /> <View className="absolute -right-1 -bottom-1"> <SymbolView name="arrow.triangle.branch" size={9} tintColorClassName={"accent-icon-muted"} type="monochrome" /> </View> </View> ); } export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; readonly projectId?: string; readonly branch?: string | null; readonly worktreePath?: string | null; }; /** Queued outbox message id when editing an existing pending task. */ readonly pendingTaskId?: string; /** Existing new-task draft key to resume (a Draft row in the thread list). */ readonly draftId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string; }) { const projects = useProjects(); const flow = useNewTaskFlow(); const navigation = useNavigation(); const { consumeShare, getShare, isLoading: isIncomingShareInboxLoading, releaseShareReservation, reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentBlockReason = selectedProject ? composerAttachmentUploadBlockReason({ environmentId: selectedProject.environmentId, attachments: flow.attachments, connected: environmentConnected, serverConfig: selectedEnvironmentServerConfig, states: uploadStates, }) : null; // A connected composer with uploads still in flight queues the task rather // than making the user wait: the outbox drain finishes the upload and sends. const attachmentsUploading = environmentConnected && selectedProject !== null && composerAttachmentsStillUploading({ environmentId: selectedProject.environmentId, attachments: flow.attachments, serverConfig: selectedEnvironmentServerConfig, states: uploadStates, }); const queuesInsteadOfStarting = !environmentConnected || attachmentsUploading; const promptInputRef = useRef<ComposerEditorHandle>(null); const loadedBranchesProjectKeyRef = useRef<string | null>(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); const wasFocusedBeforePreviewRef = useRef(false); const openVideoPreview = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasFocusedBeforePreviewRef.current = isComposerFocused; setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isComposerFocused], ); const openFilePreview = useCallback( (source: FilePreviewSource) => { wasFocusedBeforePreviewRef.current = isComposerFocused; setPreviewVideo(null); setPreviewFile((current) => current ?? source); }, [isComposerFocused], ); const closeMediaPreview = useCallback(() => { setPreviewVideo(null); setPreviewFile(null); if (wasFocusedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) promptInputRef.current?.focus(); }, 100); } }, [navigation]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, }); useEffect(() => { if (Platform.OS !== "ios") { return; } navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible }); }, [isKeyboardVisible, navigation]); useEffect(() => { return () => { if (Platform.OS === "ios") { navigation.getParent()?.setOptions({ gestureEnabled: true }); } }; }, [navigation]); const settingsRoutePresentedRef = useRef(false); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = true; navigation.dispatch(StackActions.push("ThreadSettings")); }, [navigation, settingsSheetPresentation.isVisible]); useFocusEffect( useCallback(() => { if (!settingsRoutePresentedRef.current) { return; } settingsRoutePresentedRef.current = false; settingsSheetPresentation.onDismissed(); }, [settingsSheetPresentation.onDismissed]), ); useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", settingsSheetPresentation.onStackTransitionsFinished, ), [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); const [importingShareKey, setImportingShareKey] = useState<string | null>(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); const [submitNavigationAction, setSubmitNavigationAction] = useState<NavigationAction | null>( null, ); const [shareImportAttempt, setShareImportAttempt] = useState(0); const startedShareImportKeyRef = useRef<string | null>(null); const cancellingShareImportKeyRef = useRef<string | null>(null); const shareImportDraftBackupRef = useRef(new Map<string, ComposerDraft>()); const activeShareImportTokenRef = useRef<symbol | null>(null); const shareImportMountedRef = useRef(true); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; const isImportingShare = importingShareKey !== null; const alertedUnavailableIncomingShareIdRef = useRef<string | null>(null); const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; const requestedInitialProjectAvailable = Boolean( props.initialProjectRef?.environmentId && props.initialProjectRef.projectId && projects.some( (project) => project.environmentId === props.initialProjectRef?.environmentId && project.id === props.initialProjectRef?.projectId, ), ); const isProjectPickerReturnActive = isReturningToProjectPicker && !requestedInitialProjectAvailable; const isIncomingShareAwaitingServerConfig = Boolean( incomingShare?.attachments.some((attachment) => attachment.type === "file") && selectedEnvironmentServerConfig === null, ); const isIncomingShareTransferPending = Boolean( incomingShare && cancelledIncomingShareId !== props.incomingShareId && !isIncomingShareAwaitingServerConfig, ); const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. // T3 owns /usage-limits only where Limits has data for the selected provider. const offersUsageLimits = flow.selectedProviderStatus !== null && hasProviderUsageLimits( flow.selectedProviderStatus.driver, selectedEnvironmentServerConfig?.providers ?? [], selectedEnvironmentServerConfig?.usageLimitSources ?? [], ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, environmentId: selectedProject?.environmentId ?? null, projectCwd: (flow.workspaceMode === "worktree" ? selectedProject?.workspaceRoot : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: flow.draftKey, draftMessage: flow.prompt, selection: composerMenu.selection, disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting, onChangeDraftMessage: flow.setPrompt, onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( voiceInput.state, voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; const preventRemove = (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport || flow.submitting; usePreventRemove(preventRemove, () => undefined); useEffect(() => { if (preventRemove || submitNavigationAction === null) { return; } // Give the guard update a frame to reach the parent sheet before navigating, // just like the project-picker fallback below. const frame = requestAnimationFrame(() => { setSubmitNavigationAction(null); (navigation.getParent() ?? navigation).dispatch(submitNavigationAction); }); return () => cancelAnimationFrame(frame); }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && flow.draftKey && getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), ); const isIncomingShareUnavailable = Boolean( props.incomingShareId && !isIncomingShareInboxLoading && !incomingShare && !hasImportedIncomingShare, ); const isIncomingShareReady = !props.incomingShareId || (hasImportedIncomingShare && !incomingShare) || isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef<string | null>(null); useEffect(() => { if (cancelledIncomingShareId === props.incomingShareId) { navigation.goBack(); } }, [cancelledIncomingShareId, navigation, props.incomingShareId]); useEffect(() => { if (!isReturningToProjectPicker) { return; } if (requestedInitialProjectAvailable) { setIsReturningToProjectPicker(false); return; } // Let usePreventRemove commit its disabled state before replacing this // route, otherwise the transfer guard can swallow the fallback action. const frame = requestAnimationFrame(() => { navigation.dispatch( StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), ); }); return () => cancelAnimationFrame(frame); }, [ isReturningToProjectPicker, navigation, props.incomingShareId, requestedInitialProjectAvailable, ]); useEffect(() => { if (!shareImportMountedRef.current) { startedShareImportKeyRef.current = null; } shareImportMountedRef.current = true; return () => { appliedInitialProjectKeyRef.current = null; shareImportMountedRef.current = false; activeShareImportTokenRef.current = null; cancellingShareImportKeyRef.current = null; }; }, []); const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; // A Draft row opens its own draft; a fresh New Task never reuses one. // Drafts hydrate from disk and projects arrive with the shell snapshot, so // on a cold launch the draft or its project can be missing for a moment; // wait for hydration and retry while projects load. Attempt each id once // after that so a draft discarded mid-session does not keep bouncing to // the picker. const attemptedDraftIdRef = useRef<string | null>(null); useEffect(() => { if (!props.draftId || props.pendingTaskId) { return; } const draftId = props.draftId; if (attemptedDraftIdRef.current === draftId) { return; } let cancelled = false; void waitForComposerDraftsLoaded().then(() => { if (cancelled || attemptedDraftIdRef.current === draftId) { return; } if (openDraft(draftId)) { attemptedDraftIdRef.current = draftId; return; } if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { // The draft exists; its project has not arrived yet. Retry on the // next projects change instead of giving up. return; } attemptedDraftIdRef.current = draftId; navigation.dispatch(StackActions.replace("NewTask")); }); return () => { cancelled = true; }; }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); const attemptedPendingTaskIdRef = useRef<string | null>(null); useEffect(() => { if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { return; } // Attempt each pending task once: after it is delivered or deleted the // editing session legitimately ends, and re-running must not navigate. if (attemptedPendingTaskIdRef.current === props.pendingTaskId) { return; } attemptedPendingTaskIdRef.current = props.pendingTaskId; if (!beginEditingPendingTask(props.pendingTaskId)) { // The queued task no longer exists (sent or deleted before opening). navigation.dispatch(StackActions.replace("NewTask")); } }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]); useEffect(() => { if (!props.pendingTaskId) return; return () => { // Allow a later navigation for the same pending task to re-hydrate it. attemptedPendingTaskIdRef.current = null; cancelEditingPendingTask(); }; }, [props.pendingTaskId, cancelEditingPendingTask]); const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); // A new navigation to this mounted screen delivers a fresh initialProjectRef