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 (
);
}
return (
);
}
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(null);
const loadedBranchesProjectKeyRef = useRef(null);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const [previewVideo, setPreviewVideo] = useState(null);
const [previewFile, setPreviewFile] = useState(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(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null);
const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
const [submitNavigationAction, setSubmitNavigationAction] = useState(
null,
);
const [shareImportAttempt, setShareImportAttempt] = useState(0);
const startedShareImportKeyRef = useRef(null);
const cancellingShareImportKeyRef = useRef(null);
const shareImportDraftBackupRef = useRef(new Map());
const activeShareImportTokenRef = useRef(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(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(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(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(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
// reference — treat it as a new request and let it apply again.
const lastInitialProjectRefRef = useRef(props.initialProjectRef);
useEffect(() => {
// Pending-task editing and draft resumption own project selection (and
// must not fall through to the replace("NewTask") fallback while their
// hydration is in flight).
if (props.pendingTaskId || props.draftId) {
return;
}
if (lastInitialProjectRefRef.current !== props.initialProjectRef) {
lastInitialProjectRefRef.current = props.initialProjectRef;
appliedInitialProjectKeyRef.current = null;
}
const initialEnvironmentId = props.initialProjectRef?.environmentId;
const initialProjectId = props.initialProjectRef?.projectId;
if (initialEnvironmentId && initialProjectId) {
const directProject =
projects.find(
(project) =>
project.environmentId === initialEnvironmentId && project.id === initialProjectId,
) ?? null;
if (directProject) {
// Apply the route's project once. Re-applying on every change would
// instantly revert environment/project switches made in the picker.
const directProjectKey = `${directProject.environmentId}:${directProject.id}`;
if (appliedInitialProjectKeyRef.current === directProjectKey) {
return;
}
if (props.initialProjectRef?.branch) {
if (
selectedProject?.environmentId !== directProject.environmentId ||
selectedProject.id !== directProject.id
) {
setProject(directProject);
return;
}
if (!flow.draftKey) return;
// The route completes checkout before mounting this composer. Local
// mode reuses an existing worktree; worktree mode would create another.
updateComposerDraftSettings(flow.draftKey, {
workspaceSelection: {
mode: "local",
branch: props.initialProjectRef.branch,
worktreePath: props.initialProjectRef.worktreePath ?? null,
startFromOrigin: false,
},
});
}
appliedInitialProjectKeyRef.current = directProjectKey;
if (
selectedProject?.environmentId === directProject.environmentId &&
selectedProject.id === directProject.id
) {
return;
}
setProject(directProject);
return;
}
if (projects.length > 0) {
// Never fall through to the flow provider's temporary first-project
// default. Return to the picker with the share id intact so the user
// can choose an available destination.
setIsReturningToProjectPicker(true);
}
return;
}
const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes);
if (selection.kind === "preserve") {
return;
}
if (selection.kind === "select") {
setProject(selection.project);
return;
}
navigation.dispatch(StackActions.replace("NewTask"));
}, [
projectScopes,
projects,
flow.draftKey,
props.initialProjectRef,
props.incomingShareId,
props.pendingTaskId,
props.draftId,
navigation,
selectedProject,
selectedProjectKey,
setProject,
]);
useEffect(() => {
if (!selectedProject) {
loadedBranchesProjectKeyRef.current = null;
return;
}
const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`;
if (loadedBranchesProjectKeyRef.current === projectKey) {
return;
}
loadedBranchesProjectKeyRef.current = projectKey;
flow.loadBranches();
}, [flow.loadBranches, selectedProject]);
useEffect(() => {
const shareId = props.incomingShareId;
const draftKey = flow.draftKey;
const destinationProject = selectedProject;
const initialEnvironmentId = props.initialProjectRef?.environmentId;
const initialProjectId = props.initialProjectRef?.projectId;
const selectedProjectMatchesRoute =
!initialEnvironmentId ||
!initialProjectId ||
(destinationProject?.environmentId === initialEnvironmentId &&
destinationProject.id === initialProjectId);
if (
!shareId ||
!draftKey ||
!destinationProject ||
!selectedProjectMatchesRoute ||
cancelledIncomingShareId === shareId
) {
return;
}
const importKey = `${shareId}:${draftKey}`;
if (
startedShareImportKeyRef.current === importKey ||
cancellingShareImportKeyRef.current === importKey
) {
return;
}
if (!incomingShare) {
if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) {
alertedUnavailableIncomingShareIdRef.current = shareId;
Alert.alert(
"Shared content unavailable",
"The shared content is no longer in the inbox. You can continue editing this task draft.",
);
}
return;
}
if (
incomingShare.attachments.some((attachment) => attachment.type === "file") &&
selectedEnvironmentServerConfig === null
) {
return;
}
if (alertedUnavailableIncomingShareIdRef.current === shareId) {
alertedUnavailableIncomingShareIdRef.current = null;
}
startedShareImportKeyRef.current = importKey;
const draftBackup =
shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey);
shareImportDraftBackupRef.current.set(importKey, draftBackup);
const importToken = Symbol(importKey);
let didReserveShare = false;
let didConsumeShare = false;
let needsDraftRestore = false;
activeShareImportTokenRef.current = importToken;
setImportingShareKey(importKey);
void (async () => {
await reserveShare(shareId, {
environmentId: String(destinationProject.environmentId),
projectId: String(destinationProject.id),
});
didReserveShare = true;
if (
!shareImportMountedRef.current ||
activeShareImportTokenRef.current !== importToken ||
latestDraftKeyRef.current !== draftKey ||
latestIncomingShareIdRef.current !== shareId
) {
return;
}
const selectedAttachments = selectIncomingShareAttachmentsForServer({
attachments: incomingShare.attachments,
serverConfig: appAtomRegistry.get(
serverEnvironment.configValueAtom(destinationProject.environmentId),
),
});
if (selectedAttachments.status === "pending") {
throw new Error("Server attachment support is still loading.");
}
needsDraftRestore = true;
const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, {
text: incomingShare.text,
attachments: selectedAttachments.attachments,
sourceShareId: shareId,
});
if (
!shareImportMountedRef.current ||
activeShareImportTokenRef.current !== importToken ||
latestDraftKeyRef.current !== draftKey ||
latestIncomingShareIdRef.current !== shareId
) {
// The durable reservation makes an interrupted transfer resume only
// in this project instead of copying into a second project draft.
return;
}
await consumeShare(shareId);
didConsumeShare = true;
// The consumed inbox draft was the last owner of files that never made
// it into the composer draft (unsupported server, oversize, limit
// skips). Release them before any early return: an unmount or a
// superseding import must not leak them, and the sweep re-checks
// ownership so it cannot delete a file another draft picked up.
const retainedAttachmentIds = new Set(
getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id),
);
scheduleUnusedComposerAttachmentCleanup(
incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)),
);
if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
return;
}
const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings];
if (skippedAttachmentCount > 0) {
warnings.push(
`${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`,
);
}
if (warnings.length > 0) {
Alert.alert("Some shared content was skipped", warnings.join("\n"));
}
shareImportDraftBackupRef.current.delete(importKey);
})()
.catch((error) => {
if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) {
return;
}
Alert.alert(
"Could not import shared content",
error instanceof Error ? error.message : "The shared content could not be saved.",
[
{
text: "Cancel import",
style: "cancel",
onPress: () => {
const cancelImport = async (): Promise => {
if (!shareImportMountedRef.current) {
return;
}
// Latch synchronously before restoring the draft. The
// restore publishes atom state and can re-run the import
// effect before React commits the cancelling state update.
cancellingShareImportKeyRef.current = importKey;
setIsCancellingShareImport(true);
try {
if (needsDraftRestore) {
// The restore drops the share's merged-in attachments
// from the draft. Sweep them only when the inbox entry
// was consumed: before that, the inbox still references
// these files and must keep them for a later import.
const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments;
await restoreComposerDraftSnapshot(draftKey, draftBackup);
needsDraftRestore = false;
if (didConsumeShare) {
scheduleUnusedComposerAttachmentCleanup(mergedAttachments);
}
}
if (didReserveShare) {
await releaseShareReservation(shareId, {
environmentId: String(destinationProject.environmentId),
projectId: String(destinationProject.id),
});
}
shareImportDraftBackupRef.current.delete(importKey);
if (shareImportMountedRef.current) {
setIsCancellingShareImport(false);
setCancelledIncomingShareId(shareId);
}
} catch (cancelError) {
if (!shareImportMountedRef.current) {
return;
}
Alert.alert(
"Could not cancel import",
cancelError instanceof Error
? cancelError.message
: "The shared content could not be restored safely.",
[
{
text: "Retry import",
onPress: () => {
cancellingShareImportKeyRef.current = null;
setIsCancellingShareImport(false);
setShareImportAttempt((attempt) => attempt + 1);
},
},
{
text: "Retry cancel",
onPress: () => void cancelImport(),
},
],
{ cancelable: false },
);
}
};
void cancelImport();
},
},
{
text: "Retry",
onPress: () => setShareImportAttempt((attempt) => attempt + 1),
},
],
{ cancelable: false },
);
})
.finally(() => {
if (startedShareImportKeyRef.current === importKey) {
// Every terminal path, including an invalidated operation, must
// release the synchronous start latch so this transfer can retry.
startedShareImportKeyRef.current = null;
}
if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) {
activeShareImportTokenRef.current = null;
setImportingShareKey(null);
}
});
}, [
consumeShare,
cancelledIncomingShareId,
flow.draftKey,
hasImportedIncomingShare,
incomingShare,
isIncomingShareInboxLoading,
isIncomingShareUnavailable,
props.incomingShareId,
props.initialProjectRef?.environmentId,
props.initialProjectRef?.projectId,
releaseShareReservation,
reserveShare,
selectedEnvironmentServerConfig,
selectedProject,
shareImportAttempt,
]);
const selectedEnvironmentLabel =
flow.environments.find(
(environment) => environment.environmentId === flow.selectedEnvironmentId,
)?.environmentLabel ?? "Environment";
const availableCurrentBranchName =
flow.availableBranches.find((branch) => branch.current)?.name ??
flow.availableBranches.find((branch) => branch.isDefault)?.name ??
null;
const selectedBranchName = resolveProjectThreadCreationBranch({
workspaceMode: flow.workspaceMode,
selectedBranch:
flow.selectedBranchName ??
(flow.workspaceMode === "worktree" ? availableCurrentBranchName : null),
currentCheckoutBranch: flow.currentCheckoutBranchName,
});
const selectedBranchLabel = resolveNewTaskBranchLabel({
branchName: selectedBranchName,
startFromOrigin: flow.startFromOrigin,
workspaceMode: flow.workspaceMode,
});
const workspaceLabel = resolveNewTaskWorkspaceLabel({
workspaceMode: flow.workspaceMode,
worktreePath: flow.selectedWorktreePath,
});
const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
async function handlePickMedia(): Promise {
if (isComposerInteractionLocked || voiceInput.isBusy) {
return;
}
const capabilities = selectedEnvironmentServerConfig?.environment.capabilities;
const result = await pickComposerMedia({
existingCount: flow.attachments.length,
maxVideoBytes:
capabilities?.attachmentUploads === true
? capabilities.fileAttachments?.maxUploadBytes
: undefined,
});
const rejectedCount =
result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0;
const problems = [
...(result.error ? [result.error] : []),
...(rejectedCount > 0
? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
: []),
];
if (problems.length > 0) {
Alert.alert("Could not attach photo or video", problems.join("\n\n"));
}
}
async function handlePickFiles(): Promise {
if (isComposerInteractionLocked || voiceInput.isBusy) {
return;
}
const maxBytes =
selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes;
if (maxBytes === undefined) {
Alert.alert("File attachments are not available on this server.");
return;
}
const result = await pickComposerFiles({
existingCount: flow.attachments.length,
maxBytes,
});
const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0;
// The picker error and the live-cap rejection can both happen in one
// pick; report both in a single alert.
const problems = [
...(result.error ? [result.error] : []),
...(rejectedCount > 0
? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`]
: []),
];
if (problems.length > 0) {
Alert.alert("Could not attach file", problems.join("\n\n"));
}
}
const handleNativePasteImages = useCallback(
async (uris: ReadonlyArray) => {
try {
const images = await convertPastedImagesToAttachments({
uris,
existingCount: flow.attachments.length,
});
if (images.length > 0) {
flow.appendAttachments(images);
}
} catch (error) {
console.error("[native paste] error converting images", error);
}
},
[flow],
);
async function handleStart(): Promise {
if (voiceInput.blocksSubmission) return;
const selectedProject = flow.selectedProject;
const draftKey = flow.draftKey;
if (!selectedProject || !draftKey) {
return;
}
const draft = getComposerDraftSnapshot(draftKey);
// Read the latest explicit pick. Antigravity selections stay unchanged
// when setup or a catalog change makes them unavailable.
const modelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const initialMessageText = draft.text.trim();
if (
attachmentBlockReason !== null ||
!modelSelection ||
initialMessageText.length === 0 ||
flow.submitting ||
(workspaceMode === "worktree" && !selectedBranchName)
) {
return;
}
if (
environmentConnected &&
isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection)
) {
Alert.alert(
"Antigravity model unavailable",
"Set up Antigravity on web or desktop, or choose another model.",
);
return;
}
// T3's own limits command is answered by the thread composer; a new task would
// send it to the agent. A provider's same-named command, or a prompt carrying
// attachments, goes through as usual.
if (
offersUsageLimits &&
isUsageLimitsCommand(initialMessageText) &&
draft.attachments.length === 0
) {
Alert.alert(
"Usage limits",
"Send /usage-limits inside a thread, or open Settings → Usage → Limits.",
);
return;
}
// A failed-send restore can leave the draft over the cap on purpose (it
// never drops the user's files); starting anyway would upload everything
// and have the server reject the turn.
if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
Alert.alert(
"Too many attachments",
`Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`,
);
return;
}
const editingPendingTask = flow.editingPendingTask;
// Every submission goes through the outbox: the drain uploads the
// attachments and delivers the creation, retrying across reconnects.
// When it can send now the thread screen opens immediately with the
// queued prompt and reports setup progress there, like the web draft
// does. Offline, or with uploads still in flight, the task stays a
// pending task and the sheet closes. Editing an existing pending task
// re-queues it under its original identifiers.
const metadata = editingPendingTask
? {
threadId: editingPendingTask.threadId,
commandId: editingPendingTask.commandId,
messageId: editingPendingTask.messageId,
createdAt: editingPendingTask.createdAt,
}
: makeTurnCommandMetadata();
const message = flow.buildPendingTaskMessage(metadata, {
// A task that waits in the outbox cannot know the checkout it will
// drain against; one that sends now runs against the live one.
currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
});
if (!message) {
return;
}
if (!queuesInsteadOfStarting) {
// Arm the lock-screen card before the async thread creation: backgrounding
// the app right after tapping submit would otherwise reject the foreground
// -only Activity start. If creation fails, the token registration's replay
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: selectedProject.environmentId,
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
projectTitle: selectedProject.title,
});
}
// Persist before clearing the draft or leaving its editor. This only waits
// for the local outbox write; server and worktree setup run on the thread.
flow.setSubmitting(true);
try {
await enqueueThreadOutboxMessage(message);
} catch (error) {
Alert.alert(
"Could not queue task",
error instanceof Error ? error.message : "The task could not be saved to the outbox.",
);
return;
} finally {
flow.setSubmitting(false);
}
const draftSnapshot = getComposerDraftSnapshot(draftKey);
if (editingPendingTask) {
flow.finishEditingPendingTask();
} else {
// Drop draft-local model/workspace selections with the content. The
// next task re-resolves project defaults before sticky app defaults.
// The queued message owns the attachments now, so the sweep is deferred
// until the write confirms it.
clearComposerDraftContent(draftKey, {
clearModelSelection: true,
clearWorkspaceSelection: true,
deferAttachmentCleanup: true,
});
}
setSubmitNavigationAction(
queuesInsteadOfStarting
? CommonActions.goBack()
: StackActions.replace("Thread", {
environmentId: String(message.environmentId),
threadId: String(message.threadId),
}),
);
scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
}
if (!selectedProject) {
return (
{Platform.OS === "android" ? (
<>
navigation.goBack()} />
>
) : (
)}
);
}
const isAndroid = Platform.OS === "android";
const canStart =
attachmentBlockReason === null &&
!modelUnavailable &&
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
flow.prompt.trim().length > 0 &&
isIncomingShareReady &&
!isImportingShare &&
!flow.submitting &&
!voiceInput.blocksSubmission &&
!(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
const promptEditor = (
setIsComposerFocused(true)}
onBlur={() => setIsComposerFocused(false)}
onPasteImages={(uris) => void handleNativePasteImages(uris)}
placeholder="Ask anything…"
singleLineCentered={false}
contentInsetVertical={0}
style={{
minHeight: 72,
maxHeight: 160,
paddingVertical: 4,
}}
textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
/>
);
const closeNewTask = () => {
void KeyboardController.dismiss({ animated: true });
const parentNavigation = navigation.getParent();
if (parentNavigation) {
parentNavigation.goBack();
return;
}
navigation.goBack();
};
const chooseProject = () => {
if (isComposerInteractionLocked) {
return;
}
promptInputRef.current?.blur();
void KeyboardController.dismiss({ animated: true });
navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
};
const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
if (isComposerInteractionLocked) {
return;
}
promptInputRef.current?.blur();
void KeyboardController.dismiss({ animated: true });
navigation.dispatch(StackActions.push(routeName));
};
const hero = (
What should we build
in
{selectedProject.title}
?
}
label={`on ${selectedEnvironmentLabel}`}
maxWidth={260}
onPress={
flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
}
showChevron={flow.environments.length > 1}
static={flow.environments.length <= 1}
/>
);
const heroViewport = (
{hero}
);
const workspaceControls = (
}
label={workspaceLabel}
maxWidth={flow.workspaceMode === "local" ? 220 : 148}
onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
showChevron={false}
/>
openContextPicker("NewTaskBranch")}
/>
);
const composerDock = (
{!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? (
) : null}
{workspaceControls}
{modelUnavailable ? (
Model unavailable. Open model settings.
) : null}
{flow.attachments.length > 0 ? (
undefined
: flow.removeAttachment
}
onPressPreview={
isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
}
onPressVideo={
isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
}
/>
) : null}
{promptEditor}
{isVoiceInputPresented ? (
) : (
<>
}
label={flow.selectedModelOption?.label ?? "Choose model"}
maxWidth="100%"
onPress={settingsSheetPresentation.open}
/>
{flow.planModeEnabled ? (
flow.setInteractionMode(
flow.interactionMode === "plan" ? "default" : "plan",
)
}
showChevron={false}
/>
) : null}
>
)}
{voicePresentation.showsSend ? (
void handleStart()}
variant="primary"
/>
) : null}
);
if (isAndroid) {
return (
{heroViewport}
{composerDock}
);
}
return (
{heroViewport}
{composerDock}
);
}