1473 lines · 55.9 KB · TypeScript
RawDownload
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

Lines 1–500 of 1473.