1import { useSidebarWorkspaceTabs } from "../layout/workspace/useSystemWorkspaceTabBar";
2import { Button } from "../../components/ui/Button";
3import * as Dialog from "../../components/ui/Dialog";
4import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
5import { useDeviceType } from "../../native/native-layout-metrics";
6import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
7import {
8 useAppearancePreferences,
9 useAndroidFloatingTabs,
10} from "../settings/appearance/AppearancePreferencesProvider";
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
12import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
13import { AsyncResult } from "effect/reactivity";
14import { useAtomValue } from "@effect/atom-react";
15import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
16import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste";
17import {
18 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
19 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
20 type ChatAttachment,
21 type EnvironmentId,
22 type MessageId,
23 type ModelSelection,
24 type ServerConfig as T3ServerConfig,
25 type UsageLimitsReport,
26} from "@t3tools/contracts";
27import {
28 collectProviderUsageLimits,
29 hasProviderUsageLimits,
30 isUsageLimitsCommand,
31} from "@t3tools/shared/usageLimits";
32import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
33import type { ReactNode } from "react";
34import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "react";
35import { I18nManager, Keyboard, Platform, View, type ViewStyle } from "react-native";
36import { composerImageGallery } from "../../lib/composerImageGallery";
37import { filePreviewGallery } from "../../lib/filePreviewGallery";
38import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
39import {
40 composerAttachmentUploadBlockReason,
41 composerAttachmentsStillUploading,
42 composerAttachmentUploadsAtom,
43} from "../../state/composer-attachment-uploads";
44import Animated, {
45 FadeIn,
46 FadeOut,
47 type LayoutAnimationFunction,
48 ReduceMotion,
49 useAnimatedStyle,
50 useDerivedValue,
51 useSharedValue,
52 withTiming,
53} from "react-native-reanimated";
54import { useUniwindTheme } from "../../lib/useUniwindTheme";
55import { themeColorWithAlpha } from "../../lib/mobileTheme";
56import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
57import { scopedThreadKey } from "../../lib/scopedEntities";
58import {
59 getComposerDraftSnapshot,
60 setComposerDraftText,
61 composerContextImportsAtom,
62 countComposerDraftAttachmentsAfterSelection,
63 useComposerDraft,
64} from "../../state/use-composer-drafts";
65import type { ComposerDocumentAttachment } from "../../lib/composerContext";
66import { useProject, useVisibleThreadShells } from "../../state/entities";
67import { useMentionableEnvironments } from "../../state/environments";
68import { useLinkedEnvironments } from "../../state/peerLinks";
69import { scopeProjectRef } from "@t3tools/client-runtime/environment";
70
71import { AppText as Text } from "../../components/ui/AppText";
72import { ComposerAttachmentButton } from "./ComposerAttachmentButton";
73import {
74 ComposerAttachmentStrip,
75 ComposerAttachmentThumbnail,
76} from "../../components/ComposerAttachmentStrip";
77import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal";
78import { GlassSurface } from "../../components/ui/GlassSurface";
79import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
80import { fileRoutePathSegments } from "../files/filePath";
81import { ComposerActionButton, ComposerToolbarRow } from "../../components/ComposerToolbar";
82import { ComposerModelControl } from "../../components/ComposerModelControl";
83import {
84 composerStripAttachments,
85 type DraftComposerFileAttachment,
86} from "../../lib/composerImages";
87import {
88 buildModelOptions,
89 isModelSelectionUnavailable,
90 serverConfigForCwd,
91} from "../../lib/modelOptions";
92import { useScaledTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole";
93import type { RemoteClientConnectionState } from "../../lib/connection";
94import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
95import { Menu } from "../../components/ui/Menu";
96import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
97import type { FollowUpBehavior } from "../../lib/followUpBehavior";
98import {
99 resolveComposerSendPresentation,
100 type ComposerSendPresentation,
101} from "./composerSendPresentation";
102import { compactEditorInsets } from "./compactEditorInsets";
103import { ComposerCommandPopover } from "./ComposerCommandPopover";
104import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor";
105import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit";
106import { useComposerCommandMenu } from "./use-composer-command-menu";
107import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems";
108import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request";
109import {
110 ComposerDictationCancelAction,
111 ComposerDictationDraftContent,
112 ComposerDictationError,
113 ComposerDictationPrimaryAction,
114 ComposerDictationStartAction,
115 ComposerDictationStatus,
116 ComposerDictationToolbar,
117} from "../voice-input/ComposerDictationControl";
118import { useVoiceInputController } from "../voice-input/useVoiceInputController";
119import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";
120import type { ExistingThreadSettingsRouteParams } from "./ThreadSettingsSheet";
121import {
122 useThreadSettingsSheetPresentation,
123 type NavigationWithFinishTransitioning,
124} from "./use-thread-settings-sheet-presentation";
125
126export const COMPOSER_COLLAPSED_CHROME = 60;
127
128const NO_MACHINES: ReadonlyArray<ComposerEnvironmentCandidate> = [];
129const COLLAPSED_EDITOR_DEFAULT_INSET = 6;
130// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is
131// descender (0.31em) + half x-height (0.504em) above the line bottom.
132const LOWERCASE_MIDLINE_EM = 0.562;
133
134function lowercaseMidline(text: ScaledTextRole): number {
135 return text.lineHeight - LOWERCASE_MIDLINE_EM * text.fontSize;
136}
137
138function collapsedEditorInset(text: ScaledTextRole): number {
139 const shift = lowercaseMidline(text) - lowercaseMidline(MOBILE_TYPOGRAPHY.body);
140 return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift));
141}
142
143function useCompactComposerEditor(text: ScaledTextRole, value: string) {
144 const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
145 const [compactWidth, setCompactWidth] = useState(0);
146 const contentHeight = contentSize.height;
147 const rowWidth = useSharedValue(0);
148 const editorX = useSharedValue(0);
149 const editorWidth = useSharedValue(0);
150 const inset =
151 Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text);
152 const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset);
153 const height = Math.max(
154 minimum,
155 Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset),
156 );
157 // A line that fits the expanded editor may still overflow its compact slot.
158 // Compare its measured width too so widening cannot trigger a collapse loop.
159 const isExpanded =
160 value.length > 0 &&
161 (contentHeight > text.lineHeight * 1.5 ||
162 (compactWidth > 0 && contentSize.width > compactWidth));
163 const compactRowHeight = Math.max(44, minimum);
164 const isRTL = I18nManager.isRTL;
165 const editorStyle = useAnimatedStyle(() => ({
166 position: "absolute",
167 overflow: "hidden",
168 height,
169 ...(isExpanded
170 ? { left: 14, right: 14 }
171 : compactEditorInsets({
172 slotX: editorX.value,
173 slotWidth: editorWidth.value,
174 rowWidth: rowWidth.value,
175 isRTL,
176 })),
177 // The full-width editor must move above the controls in the same frame it widens.
178 bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2,
179 }));
180 return {
181 isExpanded,
182 containerStyle: { height: isExpanded ? height + 52 : compactRowHeight },
183 editorStyle,
184 onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => {
185 rowWidth.set(event.nativeEvent.layout.width);
186 },
187 onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => {
188 // The expanded toolbar gives this slot to the model control. Keep the
189 // compact geometry for the return transition, rather than recording zero.
190 if (!isExpanded) {
191 editorX.set(event.nativeEvent.layout.x);
192 editorWidth.set(event.nativeEvent.layout.width);
193 setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8));
194 }
195 },
196 editorProps: {
197 singleLineCentered: !isExpanded,
198 scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4,
199 contentInsetVertical: inset,
200 onContentSizeChange: (size: { width: number; height: number }) => {
201 setContentSize(size);
202 },
203 style: { flex: 1 },
204 },
205 };
206}
207
208export interface ThreadComposerProps {
209 readonly isDraft?: boolean;
210 readonly canOperateThread: boolean;
211 readonly placeholder: string;
212 readonly contentMaxWidth?: number;
213 readonly draftControls?: ReactNode;
214 readonly bottomInset?: number;
215 readonly connectionState: RemoteClientConnectionState;
216 readonly environmentLabel: string | null;
217 readonly selectedThread: EnvironmentThreadShell;
218 readonly reportedModelSelection?: ModelSelection | null;
219 readonly hasCompactableConversation: boolean;
220 /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */
221 readonly onFreshSession?: () => void;
222 readonly offersSideChat?: boolean;
223 readonly serverConfig: T3ServerConfig | null;
224 readonly queueCount: number;
225 readonly activeThreadBusy: boolean;
226 readonly canStopThread: boolean;
227 readonly environmentId: EnvironmentId;
228 readonly projectCwd: string | null;
229 readonly sendBlockedReason?: string | null;
230 readonly interactionLocked?: boolean;
231 readonly draftKey?: string;
232 readonly queuedEdit?: {
233 readonly existingAttachments: ReadonlyArray<ChatAttachment>;
234 readonly saving: boolean;
235 readonly onRemoveExistingAttachment: (attachmentId: string) => void;
236 } | null;
237 readonly followUpBehavior: FollowUpBehavior;
238 readonly canSteerActiveTurn: boolean;
239 readonly hidden: boolean;
240 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
241 readonly onChangeDraftMessage: (value: string) => void;
242 readonly onPickDraftMedia: () => Promise<void>;
243 readonly onPickDraftFiles: () => Promise<void>;
244 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
245 readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
246 readonly onRemoveDraftImage: (imageId: string) => void;
247 readonly onStopThread: () => void;
248 /** Present while the thread's queue is held. */
249 readonly onResumeQueue: (() => Promise<void>) | null;
250 /** `stillSendable` reports whether the draft is unchanged after any wait before sending. */
251 readonly onSendMessage: (
252 followUp?: ActiveTurnComposerAction,
253 stillSendable?: () => boolean,
254 ) => Promise<MessageId | null>;
255 readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
256 readonly canSwitchProvider: boolean;
257 readonly onHeightChange?: (height: number) => void;
258 readonly onEditorFocusChange?: (focused: boolean) => void;
259}
260
261// Android KeyboardStickyView tracks the IME; concurrent timed layout morphs cause jitter.
262const COMPOSER_TRANSITION_DURATION_MS = 220;
263// Side panes animate dock width; nested horizontal transitions would make the surface trail the toolbar.
264const composerHeightTransition: LayoutAnimationFunction = (values) => {
265 "worklet";
266 const timing = {
267 duration: COMPOSER_TRANSITION_DURATION_MS,
268 reduceMotion: ReduceMotion.System,
269 };
270 return {
271 initialValues: {
272 originX: values.targetOriginX,
273 originY: values.currentOriginY,
274 width: values.targetWidth,
275 height: values.currentHeight,
276 },
277 animations: {
278 originX: values.targetOriginX,
279 originY: withTiming(values.targetOriginY, timing),
280 width: values.targetWidth,
281 height: withTiming(values.targetHeight, timing),
282 },
283 };
284};
285const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition;
286// Share of the one-row composer the draft keeps; a long model name truncates first.
287const INLINE_EDITOR_MIN_WIDTH = "40%";
288const INLINE_EDITOR_HEIGHT = 36;
289
290const COMPOSER_ATTACHMENT_ENTERING =
291 Platform.OS === "android"
292 ? FadeIn.duration(160)
293 : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System);
294
295const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
296
297const FOLLOW_UP_ACTION_LABEL = {
298 queue: "Queue",
299 steer: "Steer now",
300 restart: "Restart turn",
301} as const;
302
303const FOLLOW_UP_ACTION_SUBTITLE = {
304 queue: "Run after the current turn",
305 steer: "Interrupt what the agent is doing",
306 restart: "Start the turn over with this message",
307} as const;
308
309function SendActionButton(props: {
310 readonly size?: "regular" | "large";
311 readonly accessibilityLabel: string;
312 readonly presentation: ComposerSendPresentation;
313 readonly disabled: boolean;
314 readonly onSend: (followUp?: ActiveTurnComposerAction) => void;
315}) {
316 const { presentation } = props;
317 const button = (
318 <ComposerActionButton
319 accessibilityLabel={props.accessibilityLabel}
320 size={props.size}
321 icon={presentation.icon}
322 variant="primary"
323 disabled={props.disabled}
324 onPress={() => props.onSend()}
325 />
326 );
327 if (!presentation.offersFollowUpChoice || presentation.action === null || props.disabled) {
328 return button;
329 }
330 const actions = [presentation.action, presentation.alternate].filter(
331 (action): action is ActiveTurnComposerAction => action !== null,
332 );
333 return (
334 <Menu
335 accessibilityLabel="Choose how to send this message"
336 shouldOpenOnLongPress
337 actions={actions.map((action) => ({
338 id: action,
339 title: FOLLOW_UP_ACTION_LABEL[action],
340 subtitle: FOLLOW_UP_ACTION_SUBTITLE[action],
341 state: action === presentation.action ? ("on" as const) : ("off" as const),
342 }))}
343 onPressAction={({ nativeEvent }) =>
344 props.onSend(nativeEvent.event as ActiveTurnComposerAction)
345 }
346 >
347 {button}
348 </Menu>
349 );
350}
351
352function ComposerSurface(props: {
353 readonly children: ReactNode;
354 readonly style: ViewStyle;
355 readonly animateLayout?: boolean;
356}) {
357 const colors = useUniwindTheme();
358 const targetBorderRadius =
359 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
360 const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
361 const animatedBorderRadius = useDerivedValue(() =>
362 shouldAnimate
363 ? withTiming(targetBorderRadius, {
364 duration: COMPOSER_TRANSITION_DURATION_MS,
365 reduceMotion: ReduceMotion.System,
366 })
367 : targetBorderRadius,
368 );
369 const borderCurve = props.style.borderCurve;
370 const animatedShapeStyle = useAnimatedStyle(() => ({
371 borderRadius: animatedBorderRadius.value,
372 ...(borderCurve ? { borderCurve } : {}),
373 }));
374 const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined;
375
376 // Animate every native frame or glass and content jump to final height inside the clip.
377 return (
378 <Animated.View
379 className={
380 Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
381 }
382 layout={layoutTransition}
383 style={[
384 animatedShapeStyle,
385 {
386 overflow: "hidden",
387 },
388 ]}
389 >
390 <AnimatedGlassSurface
391 chrome="none"
392 fallbackColor={colors["--color-composer-surface"]}
393 fallbackClassName="border border-composer-border"
394 glassEffectStyle="regular"
395 pointerEvents="none"
396 tintColor="transparent"
397 layout={layoutTransition}
398 style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]}
399 >
400 {null}
401 </AnimatedGlassSurface>
402 <Animated.View
403 collapsable={false}
404 layout={layoutTransition}
405 style={[props.style, animatedShapeStyle]}
406 >
407 {props.children}
408 </Animated.View>
409 </Animated.View>
410 );
411}
412
413/** Space below the composer pill, inside the bottom inset it is given. */
414export const THREAD_COMPOSER_BOTTOM_GUTTER = 6;
415
416export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) {
417 const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
418 const { themeVariables: materialTheme } = useAppearancePreferences();
419 const composerPanel = materialTheme["--color-composer-panel"];
420 const navigation = useNavigation();
421 const foregroundColor = useUniwindTheme()["--color-foreground"];
422 const bodyText = useScaledTextRole("body");
423 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
424 const inputRef = props.editorRef ?? fallbackInputRef;
425 const [isFocused, setIsFocused] = useState(false);
426 const pendingPastedTextAttachmentCountRef = useRef(0);
427 const pendingNativeImagePasteCountRef = useRef(0);
428 const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0);
429 const settingsRoutePresentedRef = useRef(false);
430 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
431 editorRef: inputRef,
432 onPresent: () => {
433 if (settingsRoutePresentedRef.current) return;
434 settingsRoutePresentedRef.current = true;
435 // The draft composer edits the new-task flow, which owns its own settings route.
436 navigation.dispatch(
437 props.isDraft
438 ? StackActions.push("NewTaskThreadSettings")
439 : StackActions.push("ThreadSettingsSheet", {
440 environmentId: props.environmentId,
441 threadId: props.selectedThread.id,
442 draftKey: composerDraftKey,
443 } satisfies ExistingThreadSettingsRouteParams),
444 );
445 },
446 });
447 const disposeSettingsSheetPresentation = useEffectEvent(settingsSheetPresentation.dispose);
448 const wasExpandedBeforePreviewRef = useRef(false);
449 const inFlightThreadIdsRef = useRef(new Set<string>());
450
451 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
452 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
453 const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
454 const composerDraftKey = props.draftKey ?? composerOwnerKey;
455 const { text: draftMessage, attachments: draftAttachments } = useComposerDraft(composerDraftKey);
456 const queuedEdit = props.queuedEdit ?? null;
457 const hasContent =
458 draftMessage.trim().length > 0 ||
459 draftAttachments.length > 0 ||
460 (queuedEdit?.existingAttachments.length ?? 0) > 0;
461 const stripAttachments = composerStripAttachments(draftAttachments);
462 const showStopAction = !hasContent && props.canStopThread && queuedEdit === null;
463 const onResumeQueue = props.onResumeQueue;
464 const [resumingQueue, setResumingQueue] = useState(false);
465 const showResumeAction =
466 !hasContent && !showStopAction && queuedEdit === null && onResumeQueue !== null;
467 const handleResumeQueue = async () => {
468 if (resumingQueue || onResumeQueue === null) return;
469 setResumingQueue(true);
470 await onResumeQueue();
471 setResumingQueue(false);
472 };
473
474 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
475 const attachmentsUploading =
476 props.connectionState === "connected" &&
477 composerAttachmentsStillUploading({
478 environmentId: props.environmentId,
479 attachments: draftAttachments,
480 serverConfig: props.serverConfig,
481 states: uploadStates,
482 });
483 const sendPresentation = resolveComposerSendPresentation({
484 editingQueuedMessage: queuedEdit !== null,
485 running: props.activeThreadBusy,
486 canSteer: props.canSteerActiveTurn,
487 followUpBehavior: props.followUpBehavior,
488 deliveryDeferred:
489 props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading,
490 });
491 const sendLabel = sendPresentation.label;
492 const currentModelSelection = props.selectedThread.modelSelection;
493 const modelUnavailable =
494 props.connectionState === "connected" &&
495 isModelSelectionUnavailable(
496 props.serverConfig,
497 currentModelSelection,
498 props.selectedThread.projectId,
499 );
500 const selectedProviderStatus = (() => {
501 if (!props.serverConfig) return null;
502 return (
503 props.serverConfig.providers.find(
504 (p) => p.instanceId === props.selectedThread.modelSelection.instanceId,
505 ) ?? null
506 );
507 })();
508 const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
509 Keyboard.dismiss();
510 navigation.navigate("ThreadAttachment", {
511 environmentId: String(props.environmentId),
512 threadId: String(props.selectedThread.id),
513 attachmentId: attachment.attachmentId,
514 name: attachment.name,
515 mimeType: attachment.mimeType,
516 sizeBytes: String(attachment.sizeBytes),
517 draftKey: composerDraftKey,
518 });
519 };
520 const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props;
521 const usageLimitsOffered =
522 selectedProviderStatus !== null &&
523 hasProviderUsageLimits(
524 selectedProviderStatus.driver,
525 props.serverConfig?.providers ?? [],
526 props.serverConfig?.usageLimitSources ?? [],
527 );
528 const openUsageLimits = () => {
529 const report = collectProviderUsageLimits(
530 currentModelSelection.instanceId,
531 props.serverConfig?.providers ?? [],
532 props.serverConfig?.usageLimitSources ?? [],
533 Date.now(),
534 );
535 onShowUsageLimits(report);
536 if (!report) {
537 Dialog.alert("Usage limits unavailable", "This provider does not currently report limits.");
538 }
539 return report !== null;
540 };
541
542 const mentionableEnvironments = useMentionableEnvironments();
543 // Mentions name any model for the agent, so they aren't limited to models this thread can switch to.
544 const mentionModelOptions = buildModelOptions(
545 props.serverConfig,
546 currentModelSelection,
547 undefined,
548 props.selectedThread.projectId,
549 );
550 const composerMenu = useComposerCommandMenu({
551 draftMessage: draftMessage,
552 ownerKey: composerDraftKey,
553 environmentId: props.environmentId,
554 threadShells: useVisibleThreadShells(),
555 environments: offersLinkedEnvironments(props.serverConfig)
556 ? mentionableEnvironments
557 : NO_MACHINES,
558 linkedEnvironments: useLinkedEnvironments(props.environmentId),
559 currentThreadId: props.selectedThread.id,
560 projectCwd: props.projectCwd,
561 pullRequestProjectId: props.serverConfig ? (project?.id ?? null) : null,
562 pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,
563 threadPullRequests: props.selectedThread.pullRequests,
564 selectedProviderStatus,
565 hasThread: !props.isDraft,
566 hasCompactableConversation: props.hasCompactableConversation,
567 onChangeDraftMessage: props.onChangeDraftMessage,
568 offersUsageLimits: usageLimitsOffered,
569 onUsageLimits:
570 usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined,
571 modelOptions: mentionModelOptions,
572 modelProviders: props.serverConfig?.providers,
573 ...(props.onFreshSession === undefined ? {} : { onFreshSession: props.onFreshSession }),
574 offersSideChat: props.offersSideChat === true,
575 });
576 const voiceInput = useVoiceInputController({
577 ownerKey: composerDraftKey,
578 label: props.selectedThread.title || "Untitled thread",
579 readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text,
580 selection: composerMenu.selection,
581 onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text),
582 onChangeSelection: composerMenu.onSelectionChange,
583 });
584 const isSubmissionLocked =
585 props.interactionLocked === true || queuedEdit?.saving === true || voiceInput.blocksSubmission;
586 const voicePresentation = resolveVoiceComposerPresentation(
587 voiceInput.state,
588 voiceInput.elapsedSeconds,
589 );
590 const isVoiceInputPresented = voicePresentation.statusKind === "active";
591 const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;
592 const { layout } = useAdaptiveWorkspaceLayout();
593 const floatingSidebarComposer = useAndroidFloatingTabs() && layout.usesSplitView;
594 const sidebarWorkspaceTabs = useSidebarWorkspaceTabs();
595 const deviceType = useDeviceType();
596 const usesLargeControls = sidebarWorkspaceTabs === "picker" || deviceType === "pad";
597 const surfacePaddingVertical = usesLargeControls ? 9 : floatingSidebarComposer ? 6 : 2;
598 const [compactRowWidth, setCompactRowWidth] = useState(0);
599 const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1;
600 const compactEditor = useCompactComposerEditor(bodyText, draftMessage);
601 const showsCompactDictation = isVoiceInputPresented;
602 const isToolbarVisible = isVoiceInputPresented;
603 const hasEditingAttachments =
604 (queuedEdit !== null && queuedEdit.existingAttachments.length > 0) ||
605 stripAttachments.length > 0;
606 const attachmentBlockReason = composerAttachmentUploadBlockReason({
607 environmentId: props.environmentId,
608 attachments: draftAttachments,
609 connected: props.connectionState === "connected",
610 serverConfig: props.serverConfig,
611 states: uploadStates,
612 });
613 const contextImports = useAtomValue(composerContextImportsAtom);
614 const sendBlockedReason =
615 (queuedEdit?.saving === true ? "Saving…" : null) ??
616 props.sendBlockedReason ??
617 (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ??
618 attachmentBlockReason;
619 const canSend =
620 (props.canOperateThread || props.connectionState !== "connected") &&
621 hasContent &&
622 !contextImports[composerDraftKey] &&
623 !isSubmissionLocked &&
624 sendBlockedReason === null &&
625 !modelUnavailable;
626
627 // A pending sheet presentation or focus restore has no composer left to act on.
628 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- disposes the pending settings sheet presentation and focus restore on unmount
629 useEffect(() => () => disposeSettingsSheetPresentation(), []);
630
631 const onPressPreview = (source: FilePreviewSource) => {
632 wasExpandedBeforePreviewRef.current = isFocused;
633 setPreviewVideo(null);
634 setPreviewFile(
635 (current) =>
636 current ?? {
637 ...source,
638 gallery: filePreviewGallery(source, composerImageGallery(draftAttachments)),
639 },
640 );
641 };
642
643 const closePreview = () => {
644 setPreviewFile(null);
645 setPreviewVideo(null);
646 if (wasExpandedBeforePreviewRef.current) {
647 setTimeout(() => {
648 if (navigation.isFocused()) inputRef.current?.focus();
649 }, 100);
650 }
651 };
652
653 const onPressVideo = (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
654 wasExpandedBeforePreviewRef.current = isFocused;
655 setPreviewFile(null);
656 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
657 };
658
659 const onEditorFocusChange = props.onEditorFocusChange;
660 const handleFocus = () => {
661 setIsFocused(true);
662 settingsSheetPresentation.onEditorFocusChange(true);
663 onEditorFocusChange?.(true);
664 };
665
666 const handleBlur = () => {
667 setIsFocused(false);
668 settingsSheetPresentation.onEditorFocusChange(false);
669 onEditorFocusChange?.(false);
670 };
671 const handleSend = async (followUp?: ActiveTurnComposerAction) => {
672 if (
673 isSubmissionLocked ||
674 sendBlockedReason !== null ||
675 pendingPastedTextAttachmentCountRef.current > 0 ||
676 pendingNativeImagePasteCountRef.current > 0
677 )
678 return;
679 if (usageLimitsOffered && isUsageLimitsCommand(draftMessage) && draftAttachments.length === 0) {
680 if (openUsageLimits()) onChangeDraftMessage("");
681 return;
682 }
683 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
684 if (inFlightThreadIdsRef.current.has(threadKey)) return;
685 inFlightThreadIdsRef.current.add(threadKey);
686 await inputRef.current?.flushPendingInput();
687 const draftAtSend = getComposerDraftSnapshot(composerDraftKey);
688 const stillSendable = () => {
689 const draft = getComposerDraftSnapshot(composerDraftKey);
690 return (
691 pendingPastedTextAttachmentCountRef.current === 0 &&
692 pendingNativeImagePasteCountRef.current === 0 &&
693 draft.text === draftAtSend.text &&
694 draft.attachments.length === draftAtSend.attachments.length &&
695 draft.attachments.every(
696 (attachment, index) => attachment.id === draftAtSend.attachments[index]?.id,
697 )
698 );
699 };
700 const sent = await settlePromise(() => onSendMessage(followUp, stillSendable));
701 inFlightThreadIdsRef.current.delete(threadKey);
702 if (AsyncResult.isFailure(sent)) {
703 console.error("[composer] send failed", squashAtomCommandFailure(sent));
704 return;
705 }
706 if (sent.value === null) return;
707 // Arm after send so preference reads and native Activity startup do not delay tap-frame feedback.
708 armAgentAwarenessLiveActivityForLocalWork({
709 environmentId: props.environmentId,
710 threadTitle: props.selectedThread.title,
711 projectTitle: props.environmentLabel ?? "T3 Code",
712 });
713 };
714
715 const lockedProviderInstanceId = props.canSwitchProvider
716 ? undefined
717 : currentModelSelection.instanceId;
718 const modelOptions = buildModelOptions(
719 serverConfigForCwd(props.serverConfig, props.projectCwd),
720 currentModelSelection,
721 lockedProviderInstanceId,
722 props.selectedThread.projectId,
723 );
724 const currentModelOption =
725 modelOptions.find(
726 (option) =>
727 option.selection.instanceId === currentModelSelection.instanceId &&
728 option.selection.model === currentModelSelection.model,
729 ) ?? null;
730 const openSettings = () => {
731 settingsSheetPresentation.open();
732 };
733
734 useFocusEffect(() => {
735 if (!settingsRoutePresentedRef.current) {
736 return;
737 }
738
739 settingsRoutePresentedRef.current = false;
740 settingsSheetPresentation.onDismissed();
741 });
742
743 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the native stack's finishTransitioning event to restore the keyboard after a sheet dismisses
744 useEffect(
745 () =>
746 // The native-stack patch exposes UIKit dismissal completion; restore the keyboard then.
747 (navigation as unknown as NavigationWithFinishTransitioning).addListener(
748 "finishTransitioning",
749 settingsSheetPresentation.onStackTransitionsFinished,
750 ),
751 [navigation, settingsSheetPresentation.onStackTransitionsFinished],
752 );
753
754 // Removable attachments: inside the card, or above the focused one-row composer.
755 const editingAttachments = hasEditingAttachments ? (
756 <>
757 {queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? (
758 <Animated.View
759 className="px-[14px] pb-2.5 pt-2"
760 entering={COMPOSER_ATTACHMENT_ENTERING}
761 exiting={FadeOut.duration(120)}
762 >
763 <ComposerQueuedEditAttachments
764 environmentId={props.environmentId}
765 attachments={queuedEdit.existingAttachments}
766 disabled={isSubmissionLocked}
767 onRemove={queuedEdit.onRemoveExistingAttachment}
768 />
769 </Animated.View>
770 ) : null}
771 {stripAttachments.length > 0 ? (
772 <Animated.View
773 className="px-[14px] pb-2.5 pt-2"
774 entering={COMPOSER_ATTACHMENT_ENTERING}
775 exiting={FadeOut.duration(120)}
776 >
777 <ComposerAttachmentStrip
778 environmentId={props.environmentId}
779 attachments={stripAttachments}
780 onRemove={isSubmissionLocked ? () => undefined : props.onRemoveDraftImage}
781 onPressPreview={isSubmissionLocked ? undefined : onPressPreview}
782 onPressVideo={isSubmissionLocked ? undefined : onPressVideo}
783 onPressDocument={
784 isSubmissionLocked
785 ? undefined
786 : (attachment) =>
787 openDraftDocument({
788 attachmentId: attachment.id,
789 name: attachment.name,
790 mimeType: attachment.mimeType,
791 sizeBytes: attachment.sizeBytes,
792 })
793 }
794 />
795 </Animated.View>
796 ) : null}
797 </>
798 ) : null;
799
800 const modelControl = (
801 <View className="min-w-0 shrink">
802 <ComposerModelControl
803 size={usesLargeControls ? "large" : "regular"}
804 modelOption={currentModelOption}
805 selection={currentModelSelection}
806 provider={selectedProviderStatus?.driver}
807 reportedSelection={props.reportedModelSelection}
808 prompt={draftMessage}
809 surfaceColor={composerPanel}
810 iconOnly={!compactEditor.isExpanded}
811 onPress={openSettings}
812 />
813 </View>
814 );
815
816 return (
817 <Animated.View
818 onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)}
819 className="px-[12px]"
820 style={{
821 paddingTop: 6,
822 paddingBottom: (props.bottomInset ?? 0) + THREAD_COMPOSER_BOTTOM_GUTTER,
823 backgroundColor:
824 Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined,
825 }}
826 >
827 {/* Use a plain View: Reanimated Animated.View drops experimental_backgroundImage on Android. */}
828 <View
829 className={
830 Platform.OS === "android"
831 ? "hidden"
832 : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90"
833 }
834 pointerEvents="none"
835 />
836 <Animated.View
837 className="relative w-full self-center"
838 style={{ maxWidth: props.contentMaxWidth }}
839 >
840 {props.draftControls}
841 {!isSubmissionLocked &&
842 composerMenu.trigger &&
843 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
844 <ComposerPopoverAnchor>
845 <ComposerCommandPopover
846 items={composerMenu.items}
847 triggerKind={composerMenu.trigger.kind}
848 isLoading={composerMenu.isLoading}
849 error={composerMenu.error}
850 onSelect={composerMenu.onSelect}
851 />
852 </ComposerPopoverAnchor>
853 ) : null}
854
855 {selectedProviderStatus?.compatibilityAdvisory?.message &&
856 (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" ||
857 selectedProviderStatus.compatibilityAdvisory.status === "broken") ? (
858 <Text
859 accessibilityRole={
860 selectedProviderStatus.compatibilityAdvisory.status === "broken" ? "alert" : undefined
861 }
862 accessibilityLiveRegion={
863 selectedProviderStatus.compatibilityAdvisory.status === "broken"
864 ? "assertive"
865 : "polite"
866 }
867 className={
868 selectedProviderStatus.compatibilityAdvisory.status === "broken"
869 ? "bg-danger px-3 py-2 text-xs text-danger-foreground"
870 : "px-3 py-2 text-xs text-foreground"
871 }
872 >
873 {selectedProviderStatus.compatibilityAdvisory.message}
874 </Text>
875 ) : null}
876 {modelUnavailable ? (
877 <Button
878 label="Model unavailable. Open model settings."
879 tone="text"
880 onPress={openSettings}
881 />
882 ) : null}
883
884 <ComposerSurface
885 animateLayout={false}
886 style={{
887 borderRadius: usesLargeControls ? 31 : 27,
888 borderCurve: "circular",
889 overflow: "hidden",
890 paddingVertical: surfacePaddingVertical,
891 }}
892 >
893 <ComposerDictationError
894 error={voiceInput.state.phase === "error" ? voiceInput.state.error : null}
895 onDismiss={voiceInput.cancel}
896 />
897 {isExpanded ? editingAttachments : null}
898 <View
899 onLayout={(event) => {
900 compactEditor.onRowLayout(event);
901 setCompactRowWidth(event.nativeEvent.layout.width);
902 }}
903 >
904 <ComposerDictationDraftContent
905 className="flex-row items-end"
906 compact
907 hidden={showsCompactDictation}
908 >
909 <ComposerAttachmentButton
910 size={usesLargeControls ? "large" : "regular"}
911 disabled={isSubmissionLocked}
912 supportsFiles={props.serverConfig !== null}
913 onPickMedia={props.onPickDraftMedia}
914 onPickFiles={props.onPickDraftFiles}
915 />
916 <Animated.View
917 className="min-w-0 flex-1"
918 onLayout={compactEditor.onEditorLayout}
919 style={[
920 {
921 minWidth:
922 compactEditor.isExpanded || stripAttachments.length > 0
923 ? 0
924 : INLINE_EDITOR_MIN_WIDTH,
925 },
926 compactEditor.containerStyle,
927 compactEditor.isExpanded ? { flex: 0, width: 0 } : undefined,
928 ]}
929 ></Animated.View>
930 {/* TalkBack orders siblings by position; outside this row the editor reads after Send. */}
931 <Animated.View
932 style={[
933 compactEditor.editorStyle,
934 showsCompactDictation ? { display: "none" } : undefined,
935 ]}
936 >
937 <ComposerEditor
938 hidden={props.hidden}
939 draftKey={composerDraftKey}
940 environmentId={props.environmentId}
941 onOpenMention={(path) => {
942 Keyboard.dismiss();
943 if (props.isDraft) {
944 navigation.navigate("NewTaskFile", {
945 environmentId: String(props.environmentId),
946 cwd: props.projectCwd ?? undefined,
947 path: fileRoutePathSegments(path),
948 });
949 } else {
950 navigation.navigate("ThreadFile", {
951 environmentId: String(props.environmentId),
952 threadId: String(props.selectedThread.id),
953 path: fileRoutePathSegments(path),
954 });
955 }
956 }}
957 onOpenAttachment={openDraftDocument}
958 chipsInert={!isExpanded}
959 onInertChipPress={() => inputRef.current?.focus()}
960 ref={inputRef}
961 multiline
962 value={draftMessage}
963 // The save uses a draft snapshot; further typing would be discarded on success.
964 readOnly={isSubmissionLocked}
965 skills={composerMenu.skills}
966 selection={composerMenu.selection}
967 onChangeText={(text) => {
968 if (!isSubmissionLocked) props.onChangeDraftMessage(text);
969 }}
970 onSelectionChange={composerMenu.onSelectionChange}
971 onPasteImages={(uris) => {
972 if (isSubmissionLocked) return;
973 pendingNativeImagePasteCountRef.current += 1;
974 void props.onNativePasteImages(uris).finally(() => {
975 pendingNativeImagePasteCountRef.current -= 1;
976 });
977 }}
978 onPasteText={(paste) => {
979 if (isSubmissionLocked) return;
980 const insertPaste = () => {
981 const insertion = replaceTextSelection({
982 value: paste.value,
983 selection: paste.selection,
984 text: paste.text,
985 });
986 const selection = { start: insertion.cursor, end: insertion.cursor };
987 props.onChangeDraftMessage(insertion.value);
988 composerMenu.onSelectionChange(selection);
989 };
990 const capabilities = props.serverConfig?.environment.capabilities;
991 const advertisedMax = capabilities?.fileAttachments?.maxUploadBytes;
992 const maxBytes =
993 advertisedMax === undefined
994 ? null
995 : clampFileAttachmentUploadBytes(advertisedMax);
996 const wouldExceedInputLimit =
997 paste.value.length -
998 Math.max(0, paste.selection.end - paste.selection.start) +
999 paste.text.length >
1000 PROVIDER_SEND_TURN_MAX_INPUT_CHARS;
1001 const canAttach =
1002 maxBytes !== null &&
1003 countComposerDraftAttachmentsAfterSelection(composerDraftKey, {
1004 text: paste.value,
1005 ...paste.selection,
1006 }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS &&
1007 new TextEncoder().encode(paste.text).byteLength <= maxBytes;
1008 if (
1009 pastedTextDisposition({
1010 text: paste.text,
1011 wouldExceedInputLimit,
1012 canAttach: true,
1013 }) === "attachment"
1014 ) {
1015 if (canAttach) {
1016 pendingPastedTextAttachmentCountRef.current += 1;
1017 setPendingPastedTextAttachmentCount(
1018 pendingPastedTextAttachmentCountRef.current,
1019 );
1020 const finishAttachment = () => {
1021 pendingPastedTextAttachmentCountRef.current = Math.max(
1022 0,
1023 pendingPastedTextAttachmentCountRef.current - 1,
1024 );
1025 setPendingPastedTextAttachmentCount(
1026 pendingPastedTextAttachmentCountRef.current,
1027 );
1028 };
1029 void props
1030 .onNativePasteText(paste)
1031 .then(finishAttachment, finishAttachment);
1032 } else if (!wouldExceedInputLimit) {
1033 insertPaste();
1034 } else {
1035 Dialog.alert(
1036 "Pasted text is too large for this message",
1037 "Remove some text or an attachment, then paste again.",
1038 );
1039 }
1040 return;
1041 }
1042 insertPaste();
1043 }}
1044 placeholder={props.placeholder}
1045 onFocus={handleFocus}
1046 onBlur={handleBlur}
1047 onSubmit={(alternate) =>
1048 void handleSend(
1049 alternate && sendPresentation.alternate !== null
1050 ? sendPresentation.alternate
1051 : undefined,
1052 )
1053 }
1054 submitTitle={sendPresentation.label}
1055 alternateSubmitTitle={
1056 sendPresentation.alternate === null
1057 ? sendPresentation.label
1058 : FOLLOW_UP_ACTION_LABEL[sendPresentation.alternate]
1059 }
1060 {...compactEditor.editorProps}
1061 textStyle={{
1062 ...bodyText,
1063 color: foregroundColor,
1064 }}
1065 />
1066 </Animated.View>
1067 {!isExpanded && stripAttachments.length > 0 ? (
1068 <View className="h-[44px] shrink-0 flex-row items-center gap-1 pl-1">
1069 {stripAttachments.slice(0, compactAttachmentLimit).map((attachment) => (
1070 <ComposerAttachmentThumbnail
1071 environmentId={props.environmentId}
1072 key={attachment.id}
1073 attachment={attachment}
1074 size={30}
1075 borderRadius={8}
1076 compact
1077 onPressPreview={isSubmissionLocked ? undefined : onPressPreview}
1078 onPressVideo={isSubmissionLocked ? undefined : onPressVideo}
1079 />
1080 ))}
1081 {stripAttachments.length > compactAttachmentLimit ? (
1082 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
1083 <Text className="text-foreground-muted text-2xs font-t3-bold">
1084 +{stripAttachments.length - compactAttachmentLimit}
1085 </Text>
1086 </View>
1087 ) : null}
1088 </View>
1089 ) : null}
1090 <View
1091 className="min-w-0 shrink flex-row items-center"
1092 style={
1093 compactEditor.isExpanded ? { flex: 1, justifyContent: "flex-end" } : undefined
1094 }
1095 >
1096 {modelControl}
1097 <ComposerDictationStartAction
1098 size={usesLargeControls ? "large" : "regular"}
1099 state={voiceInput.state}
1100 isAvailable={voiceInput.isAvailable}
1101 disabled={isSubmissionLocked}
1102 onStart={voiceInput.start}
1103 onCancel={voiceInput.cancel}
1104 />
1105 {showStopAction ? (
1106 <ComposerActionButton
1107 size={usesLargeControls ? "large" : "regular"}
1108 accessibilityLabel="Stop agent"
1109 icon="stop.fill"
1110 variant="danger"
1111 disabled={!props.canOperateThread}
1112 onPress={props.onStopThread}
1113 />
1114 ) : showResumeAction ? (
1115 <ComposerActionButton
1116 size={usesLargeControls ? "large" : "regular"}
1117 accessibilityLabel="Resume thread"
1118 icon="play"
1119 disabled={!props.canOperateThread || resumingQueue}
1120 onPress={() => void handleResumeQueue()}
1121 />
1122 ) : (
1123 <SendActionButton
1124 size={usesLargeControls ? "large" : "regular"}
1125 accessibilityLabel={sendBlockedReason ?? sendLabel}
1126 presentation={sendPresentation}
1127 disabled={!canSend}
1128 onSend={handleSend}
1129 />
1130 )}
1131 </View>
1132 </ComposerDictationDraftContent>
1133 </View>
1134 <Animated.View
1135 accessibilityElementsHidden={!isToolbarVisible}
1136 collapsable={false}
1137 importantForAccessibility={isToolbarVisible ? "auto" : "no-hide-descendants"}
1138 pointerEvents={isToolbarVisible ? "auto" : "none"}
1139 style={{ position: "absolute", bottom: surfacePaddingVertical, left: 0, right: 0 }}
1140 >
1141 <ComposerDictationToolbar
1142 showsDictation={isVoiceInputPresented}
1143 visible={isToolbarVisible}
1144 >
1145 <ComposerToolbarRow
1146 paddingBottom={0}
1147 paddingHorizontal={0}
1148 paddingTop={0}
1149 style={{ gap: 0 }}
1150 >
1151 <ComposerDictationCancelAction
1152 size={usesLargeControls ? "large" : "regular"}
1153 presentation={voicePresentation}
1154 onCancel={voiceInput.cancel}
1155 />
1156 <ComposerDictationStatus
1157 audioLevels={voiceInput.audioLevels}
1158 elapsedSeconds={voiceInput.elapsedSeconds}
1159 phase={voiceInput.state.phase}
1160 presentation={voicePresentation}
1161 />
1162 <View className="shrink-0 flex-row items-center">
1163 <ComposerDictationPrimaryAction
1164 size={usesLargeControls ? "large" : "regular"}
1165 state={voiceInput.state}
1166 presentation={voicePresentation}
1167 isAvailable={voiceInput.isAvailable}
1168 disabled={isSubmissionLocked}
1169 onStart={voiceInput.start}
1170 onConfirm={voiceInput.stop}
1171 onCancel={voiceInput.cancel}
1172 />
1173 {showStopAction ? (
1174 <ComposerActionButton
1175 size={usesLargeControls ? "large" : "regular"}
1176 accessibilityLabel="Stop agent"
1177 icon="stop.fill"
1178 variant="danger"
1179 disabled={!props.canOperateThread}
1180 onPress={props.onStopThread}
1181 />
1182 ) : showResumeAction && voicePresentation.showsSend ? (
1183 <ComposerActionButton
1184 size={usesLargeControls ? "large" : "regular"}
1185 accessibilityLabel="Resume thread"
1186 icon="play"
1187 disabled={!props.canOperateThread || resumingQueue}
1188 onPress={() => void handleResumeQueue()}
1189 />
1190 ) : voicePresentation.showsSend ? (
1191 <SendActionButton
1192 size={usesLargeControls ? "large" : "regular"}
1193 accessibilityLabel={sendBlockedReason ?? sendLabel}
1194 presentation={sendPresentation}
1195 disabled={!canSend}
1196 onSend={handleSend}
1197 />
1198 ) : null}
1199 </View>
1200 </ComposerToolbarRow>
1201 </ComposerDictationToolbar>
1202 </Animated.View>
1203 </ComposerSurface>
1204
1205 {props.connectionState === "connected" && !props.canOperateThread ? (
1206 <Text className="pt-2 text-xs text-foreground-muted">
1207 This connection cannot control this task. You can still edit your draft.
1208 </Text>
1209 ) : null}
1210 </Animated.View>
1211
1212 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
1213 <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
1214 </Animated.View>
1215 );
1216};