1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { useAtomValue } from "@effect/atom-react";
5import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
6import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste";
7import {
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
9 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
10 type ChatAttachment,
11 type EnvironmentId,
12 type MessageId,
13 type ModelSelection,
14 type ProviderInteractionMode,
15 type RuntimeMode,
16 type ServerConfig as T3ServerConfig,
17 type UsageLimitsReport,
18} from "@t3tools/contracts";
19import {
20 collectProviderUsageLimits,
21 hasProviderUsageLimits,
22 isUsageLimitsCommand,
23} from "@t3tools/shared/usageLimits";
24import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
25import type { ReactNode } from "react";
26import {
27 memo,
28 useCallback,
29 useEffect,
30 useLayoutEffect,
31 useMemo,
32 useRef,
33 useState,
34 type RefObject,
35} from "react";
36import { Alert, Keyboard, Platform, Pressable, View, type ViewStyle } from "react-native";
37import { composerImageGallery } from "../../lib/composerImageGallery";
38import { filePreviewGallery } from "../../lib/filePreviewGallery";
39import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
40import {
41 composerAttachmentUploadBlockReason,
42 composerAttachmentsStillUploading,
43 composerAttachmentUploadsAtom,
44} from "../../state/composer-attachment-uploads";
45import Animated, {
46 FadeIn,
47 FadeOut,
48 type LayoutAnimationFunction,
49 ReduceMotion,
50 useAnimatedStyle,
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 composerDraftsAtom,
61 setComposerDraftText,
62 composerContextImportsAtom,
63 countComposerDraftAttachmentsAfterSelection,
64} from "../../state/use-composer-drafts";
65import { appAtomRegistry } from "../../state/atom-registry";
66import type { ComposerDocumentAttachment } from "../../lib/composerContext";
67import { useProject, useThreadShells } from "../../state/entities";
68import { scopeProjectRef } from "@t3tools/client-runtime/environment";
69
70import { AppText as Text } from "../../components/AppText";
71import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
72import {
73 ComposerAttachmentStrip,
74 ComposerAttachmentThumbnail,
75} from "../../components/ComposerAttachmentStrip";
76import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
77import { GlassSurface } from "../../components/GlassSurface";
78import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
79import { fileRoutePathSegments } from "../files/filePath";
80import {
81 ComposerActionButton,
82 ComposerInlineControl,
83 ComposerToolbarRow,
84} from "../../components/ComposerToolbar";
85import { ProviderIcon } from "../../components/ProviderIcon";
86import {
87 composerStripAttachments,
88 type DraftComposerAttachment,
89 type DraftComposerFileAttachment,
90} from "../../lib/composerImages";
91import {
92 buildModelOptions,
93 groupByProvider,
94 isModelSelectionUnavailable,
95} from "../../lib/modelOptions";
96import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
97import type { RemoteClientConnectionState } from "../../lib/connection";
98import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
99import { ControlPillMenu } from "../../components/ControlPill";
100import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
101import type { FollowUpBehavior } from "../../lib/followUpBehavior";
102import {
103 resolveComposerSendPresentation,
104 type ComposerSendPresentation,
105} from "./composerSendPresentation";
106import { ComposerCommandPopover } from "./ComposerCommandPopover";
107import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor";
108import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit";
109import { useComposerCommandMenu } from "./use-composer-command-menu";
110import {
111 ComposerDictationCancelAction,
112 ComposerDictationDraftContent,
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 {
121 rememberModelOptions,
122 withRememberedModelOptions,
123} from "../../state/use-model-option-memory";
124import {
125 type ExistingThreadSettingsRouteSession,
126 useExistingThreadSettingsRoutePresentation,
127} from "./ThreadSettingsSheet";
128import {
129 useThreadSettingsSheetPresentation,
130 type NavigationWithFinishTransitioning,
131} from "./use-thread-settings-sheet-presentation";
132
133/**
134 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
135 * Exported so the parent can compute feed overlap / content insets.
136 */
137export const COMPOSER_COLLAPSED_CHROME = 60;
138
139/**
140 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
141 * Used by the parent to compute the larger feed bottom inset when the composer is focused.
142 */
143export const COMPOSER_EXPANDED_CHROME = 156;
144
145export interface ThreadComposerProps {
146 readonly canOperateThread: boolean;
147 readonly draftMessage: string;
148 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
149 readonly placeholder: string;
150 readonly contentMaxWidth?: number;
151 readonly bottomInset?: number;
152 readonly connectionState: RemoteClientConnectionState;
153 readonly environmentLabel: string | null;
154 /**
155 * Message sync phase for the selected thread (drives the status pill):
156 * "loading" = first fetch, nothing to show yet; "syncing" = cached messages
157 * are on screen while they reconcile with the server.
158 */
159 readonly threadSyncPhase?: "loading" | "syncing" | null;
160 readonly selectedThread: EnvironmentThreadShell;
161 readonly reportedModelSelection?: ModelSelection | null;
162 readonly hasCompactableConversation: boolean;
163 readonly serverConfig: T3ServerConfig | null;
164 readonly queueCount: number;
165 readonly activeThreadBusy: boolean;
166 readonly canStopThread: boolean;
167 readonly environmentId: EnvironmentId;
168 readonly projectCwd: string | null;
169 /** Why sending is blocked right now (shown as the send button's label), or null. */
170 readonly sendBlockedReason?: string | null;
171 /** Where the composer's content lives. Defaults to this thread's own draft. */
172 readonly draftKey?: string;
173 /**
174 * Set while a queued message is open for editing: the send button saves the
175 * edit instead of sending, and the message's server attachments stay visible
176 * above the composer so they can be removed.
177 */
178 readonly queuedEdit?: {
179 readonly existingAttachments: ReadonlyArray<ChatAttachment>;
180 readonly saving: boolean;
181 readonly onRemoveExistingAttachment: (attachmentId: string) => void;
182 } | null;
183 /** The user's configured behavior for a message sent during a running turn. */
184 readonly followUpBehavior: FollowUpBehavior;
185 /** Whether the live turn can actually be steered by this provider. */
186 readonly canSteerActiveTurn: boolean;
187 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
188 readonly onChangeDraftMessage: (value: string) => void;
189 readonly onPickDraftMedia: () => Promise<void>;
190 readonly onPickDraftFiles: () => Promise<void>;
191 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
192 readonly onNativePasteText: (paste: ComposerTextPaste) => Promise<void>;
193 readonly onRemoveDraftImage: (imageId: string) => void;
194 readonly onStopThread: () => void;
195 readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise<MessageId | null>;
196 /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */
197 readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
198 /**
199 * Whether the model picker may offer providers other than this thread's.
200 * False keeps the catalog on the instance the thread's session runs on.
201 */
202 readonly canSwitchProvider: boolean;
203 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
204 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
205 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
206 readonly onExpandedChange?: (expanded: boolean) => void;
207 /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */
208 readonly onEditorFocusChange?: (focused: boolean) => void;
209}
210
211/**
212 * The pill / card container — renders with Expo's native GlassView on supported
213 * iOS 26+ devices, with a frosted blur fallback where supported.
214 * Exported so NewTaskDraftScreen can render the same composer chrome.
215 */
216// The bottom-anchored dock position and clipped surface height use the same
217// transition so the card grows upward without exposing its final-size content.
218// Android gets NO layout transition: the composer rides the keyboard via
219// KeyboardStickyView (frame-synced to the IME), and a time-based morph
220// running alongside that translate reads as jitter. Snapping the layout and
221// letting the keyboard-synced slide be the only motion looks native there.
222export const COMPOSER_TRANSITION_DURATION_MS = 220;
223// Side panes already animate the dock's width. Nested horizontal layout
224// transitions would leave the surface trailing its toolbar's new position.
225// Keep the vertical pill/card morph while horizontal layout follows the dock.
226const composerHeightTransition: LayoutAnimationFunction = (values) => {
227 "worklet";
228 const timing = {
229 duration: COMPOSER_TRANSITION_DURATION_MS,
230 reduceMotion: ReduceMotion.System,
231 };
232 return {
233 initialValues: {
234 originX: values.targetOriginX,
235 originY: values.currentOriginY,
236 width: values.targetWidth,
237 height: values.currentHeight,
238 },
239 animations: {
240 originX: values.targetOriginX,
241 originY: withTiming(values.targetOriginY, timing),
242 width: values.targetWidth,
243 height: withTiming(values.targetHeight, timing),
244 },
245 };
246};
247export const COMPOSER_LAYOUT_TRANSITION =
248 Platform.OS === "android" ? undefined : composerHeightTransition;
249
250const COMPOSER_ATTACHMENT_ENTERING =
251 Platform.OS === "android"
252 ? FadeIn.duration(160)
253 : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System);
254
255const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface);
256
257const FOLLOW_UP_ACTION_LABEL = {
258 queue: "Queue",
259 steer: "Steer now",
260 restart: "Restart turn",
261} as const;
262
263const FOLLOW_UP_ACTION_SUBTITLE = {
264 queue: "Run after the current turn",
265 steer: "Interrupt what the agent is doing",
266 restart: "Start the turn over with this message",
267} as const;
268
269/**
270 * The composer's primary button. While a turn is running it also long-presses
271 * into the two follow-up behaviors, which is mobile's stand-in for the Command
272 * modifier a hardware keyboard has.
273 */
274function SendActionButton(props: {
275 readonly accessibilityLabel: string;
276 readonly presentation: ComposerSendPresentation;
277 readonly disabled: boolean;
278 readonly onSend: (followUp?: ActiveTurnComposerAction) => void;
279}) {
280 const { presentation } = props;
281 const button = (
282 <ComposerActionButton
283 accessibilityLabel={props.accessibilityLabel}
284 icon={presentation.icon}
285 variant="primary"
286 disabled={props.disabled}
287 onPress={() => props.onSend()}
288 />
289 );
290 if (!presentation.offersFollowUpChoice || presentation.action === null || props.disabled) {
291 return button;
292 }
293 const actions = [presentation.action, presentation.alternate].filter(
294 (action): action is ActiveTurnComposerAction => action !== null,
295 );
296 return (
297 <ControlPillMenu
298 accessibilityLabel="Choose how to send this message"
299 shouldOpenOnLongPress
300 actions={actions.map((action) => ({
301 id: action,
302 title: FOLLOW_UP_ACTION_LABEL[action],
303 subtitle: FOLLOW_UP_ACTION_SUBTITLE[action],
304 state: action === presentation.action ? ("on" as const) : ("off" as const),
305 }))}
306 onPressAction={({ nativeEvent }) =>
307 props.onSend(nativeEvent.event as ActiveTurnComposerAction)
308 }
309 >
310 {button}
311 </ControlPillMenu>
312 );
313}
314
315export function ComposerSurface(props: {
316 readonly children: ReactNode;
317 readonly style: ViewStyle;
318 /** Morphs between the compact and expanded composer layouts. */
319 readonly animateLayout?: boolean;
320}) {
321 const colors = useUniwindTheme();
322 const targetBorderRadius =
323 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
324 const animatedBorderRadius = useSharedValue(targetBorderRadius);
325 const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android";
326 useLayoutEffect(() => {
327 animatedBorderRadius.value = shouldAnimate
328 ? withTiming(targetBorderRadius, {
329 duration: COMPOSER_TRANSITION_DURATION_MS,
330 reduceMotion: ReduceMotion.System,
331 })
332 : targetBorderRadius;
333 }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]);
334 const animatedShapeStyle = useAnimatedStyle(() => ({
335 borderRadius: animatedBorderRadius.value,
336 }));
337 const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined;
338
339 // Each native frame follows the same transition. Animating only the outer
340 // clip leaves the glass and content at their final height on the first frame.
341 return (
342 <Animated.View
343 className={
344 Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
345 }
346 layout={layoutTransition}
347 style={[
348 animatedShapeStyle,
349 {
350 overflow: "hidden",
351 },
352 ]}
353 >
354 <AnimatedGlassSurface
355 chrome="none"
356 fallbackColor={colors["--color-composer-surface"]}
357 fallbackClassName="border border-composer-border"
358 glassEffectStyle="regular"
359 // The composer is a passive material containing interactive controls.
360 // Keep native glass out of the interactive content's layout path.
361 pointerEvents="none"
362 tintColor="transparent"
363 layout={layoutTransition}
364 style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]}
365 >
366 {null}
367 </AnimatedGlassSurface>
368 <Animated.View
369 collapsable={false}
370 layout={layoutTransition}
371 style={[props.style, animatedShapeStyle]}
372 >
373 {props.children}
374 </Animated.View>
375 </Animated.View>
376 );
377}
378
379export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
380 const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
381 const { themeVariables: materialTheme } = useAppearancePreferences();
382 const composerPanel = materialTheme["--color-composer-panel"];
383 const navigation = useNavigation();
384 const foregroundColor = useUniwindTheme()["--color-foreground"];
385 const bodyText = useScaledTextRole("body");
386 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
387 const inputRef = props.editorRef ?? fallbackInputRef;
388 const [isFocused, setIsFocused] = useState(false);
389 const pendingPastedTextAttachmentCountRef = useRef(0);
390 const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0);
391 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
392 editorRef: inputRef,
393 isEditorFocused: isFocused,
394 });
395 const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation();
396 const settingsRoutePresentedRef = useRef(false);
397 const wasExpandedBeforePreviewRef = useRef(false);
398 const inFlightThreadIdsRef = useRef(new Set<string>());
399 const { onExpandedChange } = props;
400
401 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
402 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
403 const queuedEdit = props.queuedEdit ?? null;
404 const hasContent =
405 props.draftMessage.trim().length > 0 ||
406 props.draftAttachments.length > 0 ||
407 (queuedEdit?.existingAttachments.length ?? 0) > 0;
408 // Only media belongs above the composer; every other file reads as its inline chip.
409 const stripAttachments = useMemo(
410 () => composerStripAttachments(props.draftAttachments),
411 [props.draftAttachments],
412 );
413 // Stopping the agent is not what the send button means in edit mode.
414 const showStopAction = !hasContent && props.canStopThread && queuedEdit === null;
415
416 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
417 const attachmentsUploading =
418 props.connectionState === "connected" &&
419 composerAttachmentsStillUploading({
420 environmentId: props.environmentId,
421 attachments: props.draftAttachments,
422 serverConfig: props.serverConfig,
423 states: uploadStates,
424 });
425 // Every send goes through the outbox; the label says whether it leaves now
426 // or waits (for the connection, an earlier queued message, or an upload).
427 const sendPresentation = resolveComposerSendPresentation({
428 editingQueuedMessage: queuedEdit !== null,
429 running: props.activeThreadBusy,
430 canSteer: props.canSteerActiveTurn,
431 followUpBehavior: props.followUpBehavior,
432 deliveryDeferred:
433 props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading,
434 });
435 const sendLabel = sendPresentation.label;
436 const currentModelSelection = props.selectedThread.modelSelection;
437 const currentRuntimeMode = props.selectedThread.runtimeMode;
438 const modelUnavailable =
439 props.connectionState === "connected" &&
440 isModelSelectionUnavailable(
441 props.serverConfig,
442 currentModelSelection,
443 props.selectedThread.projectId,
444 );
445 const selectedProviderStatus = useMemo(() => {
446 if (!props.serverConfig) return null;
447 return (
448 props.serverConfig.providers.find(
449 (p) => p.instanceId === props.selectedThread.modelSelection.instanceId,
450 ) ?? null
451 );
452 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
453 const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
454 // Content lives under the edit's own draft while a queued message is open;
455 // the owner key still identifies this composer for settings and dictation.
456 const composerDraftKey = props.draftKey ?? composerOwnerKey;
457 const openDraftDocument = (attachment: ComposerDocumentAttachment) => {
458 Keyboard.dismiss();
459 navigation.navigate("ThreadAttachment", {
460 environmentId: String(props.environmentId),
461 threadId: String(props.selectedThread.id),
462 attachmentId: attachment.attachmentId,
463 name: attachment.name,
464 mimeType: attachment.mimeType,
465 sizeBytes: String(attachment.sizeBytes),
466 draftKey: composerDraftKey,
467 });
468 };
469 const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props;
470 // T3 owns /usage-limits only where Limits has data for the selected provider;
471 // elsewhere the name stays the provider's own and is sent through untouched.
472 const usageLimitsOffered =
473 selectedProviderStatus !== null &&
474 hasProviderUsageLimits(
475 selectedProviderStatus.driver,
476 props.serverConfig?.providers ?? [],
477 props.serverConfig?.usageLimitSources ?? [],
478 );
479 // Answered locally from the last Limits snapshot; the agent never sees it.
480 const openUsageLimits = useCallback(() => {
481 const report = collectProviderUsageLimits(
482 currentModelSelection.instanceId,
483 props.serverConfig?.providers ?? [],
484 props.serverConfig?.usageLimitSources ?? [],
485 Date.now(),
486 );
487 onShowUsageLimits(report);
488 if (!report) {
489 Alert.alert("Usage limits unavailable", "This provider does not currently report limits.");
490 }
491 return report !== null;
492 }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]);
493
494 const composerMenu = useComposerCommandMenu({
495 draftMessage: props.draftMessage,
496 ownerKey: composerOwnerKey,
497 environmentId: props.environmentId,
498 threadShells: useThreadShells(),
499 currentThreadId: props.selectedThread.id,
500 projectCwd: props.projectCwd,
501 pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests
502 ? (project?.id ?? null)
503 : null,
504 pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,
505 threadPullRequests: props.selectedThread.pullRequests,
506 selectedProviderStatus,
507 hasThread: true,
508 hasCompactableConversation: props.hasCompactableConversation,
509 onChangeDraftMessage: props.onChangeDraftMessage,
510 onUpdateInteractionMode:
511 selectedProviderStatus?.showInteractionModeToggle === false
512 ? undefined
513 : props.onUpdateInteractionMode,
514 offersUsageLimits: usageLimitsOffered,
515 // With attachments aboard the pick just inserts the text, so it sends as a prompt.
516 onUsageLimits:
517 usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined,
518 });
519 const voiceInput = useVoiceInputController({
520 ownerKey: composerDraftKey,
521 label: props.selectedThread.title || "Untitled thread",
522 readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text,
523 subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange),
524 selection: composerMenu.selection,
525 onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text),
526 onChangeSelection: composerMenu.onSelectionChange,
527 });
528 const voicePresentation = resolveVoiceComposerPresentation(
529 voiceInput.state,
530 voiceInput.elapsedSeconds,
531 );
532 const isVoiceInputPresented = voicePresentation.statusLabel !== null;
533 // An open draft stays visible; only a collapsed composer becomes a voice strip.
534 const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;
535 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
536 const isToolbarVisible = isExpanded || isVoiceInputPresented;
537 const attachmentBlockReason = composerAttachmentUploadBlockReason({
538 environmentId: props.environmentId,
539 attachments: props.draftAttachments,
540 connected: props.connectionState === "connected",
541 serverConfig: props.serverConfig,
542 states: uploadStates,
543 });
544 const contextImports = useAtomValue(composerContextImportsAtom);
545 const sendBlockedReason =
546 (queuedEdit?.saving === true ? "Saving…" : null) ??
547 props.sendBlockedReason ??
548 (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ??
549 attachmentBlockReason;
550 const canSend =
551 (props.canOperateThread || props.connectionState !== "connected") &&
552 hasContent &&
553 !contextImports[composerDraftKey] &&
554 !voiceInput.blocksSubmission &&
555 sendBlockedReason === null &&
556 !modelUnavailable;
557
558 // Keep the feed inset aligned with the card or compact dictation strip.
559 useEffect(() => {
560 onExpandedChange?.(isExpanded);
561 }, [isExpanded, onExpandedChange]);
562
563 const onPressPreview = useCallback(
564 (source: FilePreviewSource) => {
565 wasExpandedBeforePreviewRef.current = isFocused;
566 setPreviewVideo(null);
567 setPreviewFile(
568 (current) =>
569 current ?? {
570 ...source,
571 gallery: filePreviewGallery(source, composerImageGallery(props.draftAttachments)),
572 },
573 );
574 },
575 [isFocused, props.draftAttachments],
576 );
577
578 const closePreview = useCallback(() => {
579 setPreviewFile(null);
580 setPreviewVideo(null);
581 if (wasExpandedBeforePreviewRef.current) {
582 setTimeout(() => {
583 if (navigation.isFocused()) inputRef.current?.focus();
584 }, 100);
585 }
586 }, [inputRef, navigation]);
587
588 const onPressVideo = useCallback(
589 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
590 wasExpandedBeforePreviewRef.current = isFocused;
591 setPreviewFile(null);
592 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
593 },
594 [isFocused],
595 );
596
597 const onEditorFocusChange = props.onEditorFocusChange;
598 const handleFocus = useCallback(() => {
599 setIsFocused(true);
600 onExpandedChange?.(true);
601 onEditorFocusChange?.(true);
602 }, [onEditorFocusChange, onExpandedChange]);
603
604 const handleBlur = useCallback(() => {
605 setIsFocused(false);
606 if (!settingsSheetPresentation.keepsComposerExpanded) {
607 onExpandedChange?.(false);
608 }
609 onEditorFocusChange?.(false);
610 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]);
611 const handleSend = useCallback(
612 async (followUp?: ActiveTurnComposerAction) => {
613 if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
614 // Typed out in full rather than picked from the menu. Attachments mean the
615 // user is sending a prompt, so those go through as usual.
616 if (
617 usageLimitsOffered &&
618 isUsageLimitsCommand(props.draftMessage) &&
619 props.draftAttachments.length === 0
620 ) {
621 if (openUsageLimits()) onChangeDraftMessage("");
622 return;
623 }
624 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
625 if (inFlightThreadIdsRef.current.has(threadKey)) return;
626 inFlightThreadIdsRef.current.add(threadKey);
627 try {
628 const messageId = await onSendMessage(followUp);
629 if (messageId === null) {
630 return;
631 }
632 // Sending a prompt starts agent work: arm the lock-screen card while the
633 // app is foregrounded and the activity token can be registered. Armed
634 // after the send so its preference read and native Activity start don't
635 // contend with the queued-message feedback on the tap frame.
636 armAgentAwarenessLiveActivityForLocalWork({
637 environmentId: props.environmentId,
638 threadTitle: props.selectedThread.title,
639 projectTitle: props.environmentLabel ?? "The Knife",
640 });
641 } finally {
642 inFlightThreadIdsRef.current.delete(threadKey);
643 }
644 },
645 [
646 props.draftMessage,
647 props.draftAttachments.length,
648 onChangeDraftMessage,
649 openUsageLimits,
650 usageLimitsOffered,
651 onSendMessage,
652 props.environmentId,
653 props.environmentLabel,
654 props.selectedThread.id,
655 props.selectedThread.title,
656 voiceInput.blocksSubmission,
657 ],
658 );
659
660 // ── Model menu ───────────────────────────────────────────
661 // A session that hands the conversation to another provider lets the picker
662 // offer the whole catalog; one that can't stays on its own instance.
663 const lockedProviderInstanceId = props.canSwitchProvider
664 ? undefined
665 : currentModelSelection.instanceId;
666 const modelOptions = useMemo(
667 () =>
668 buildModelOptions(
669 props.serverConfig,
670 currentModelSelection,
671 lockedProviderInstanceId,
672 props.selectedThread.projectId,
673 ),
674 [
675 props.serverConfig,
676 currentModelSelection,
677 lockedProviderInstanceId,
678 props.selectedThread.projectId,
679 ],
680 );
681 const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
682 const currentModelOption =
683 modelOptions.find(
684 (option) =>
685 option.selection.instanceId === currentModelSelection.instanceId &&
686 option.selection.model === currentModelSelection.model,
687 ) ?? null;
688 const providerOptionDescriptors = useMemo(
689 () =>
690 resolveProviderOptionDescriptors({
691 capabilities: currentModelOption?.capabilities,
692 selections: currentModelSelection.options,
693 }),
694 [currentModelOption?.capabilities, currentModelSelection.options],
695 );
696 const settingsOwnerId = composerOwnerKey;
697 const settingsRouteSession = useMemo<ExistingThreadSettingsRouteSession>(
698 () => ({
699 ownerId: settingsOwnerId,
700 environmentId: props.environmentId,
701 projectId: props.selectedThread.projectId,
702 providerInstanceId: currentModelSelection.instanceId,
703 ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
704 providerGroups: threadProviderGroups,
705 selectedModel: currentModelSelection,
706 reportedModelSelection: props.reportedModelSelection,
707 onSelectModel: (option) =>
708 props.onUpdateModelSelection(withRememberedModelOptions(option.selection)),
709 optionDescriptors: providerOptionDescriptors,
710 onUpdateOptionSelections: (options) => {
711 rememberModelOptions(
712 currentModelSelection.instanceId,
713 currentModelSelection.model,
714 options ?? [],
715 );
716 props.onUpdateModelSelection({ ...currentModelSelection, options });
717 },
718 runtimeMode: currentRuntimeMode,
719 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
720 }),
721 [
722 currentModelSelection,
723 props.reportedModelSelection,
724 currentRuntimeMode,
725 props.onUpdateModelSelection,
726 props.onUpdateRuntimeMode,
727 providerOptionDescriptors,
728 settingsOwnerId,
729 lockedProviderInstanceId,
730 threadProviderGroups,
731 props.selectedThread.projectId,
732 ],
733 );
734 const openSettings = useCallback(() => {
735 settingsRoutePresentation.present(settingsRouteSession);
736 settingsSheetPresentation.open();
737 }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]);
738
739 useEffect(() => {
740 if (settingsSheetPresentation.isActive) {
741 settingsRoutePresentation.present(settingsRouteSession);
742 }
743 }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]);
744
745 useEffect(() => {
746 if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
747 return;
748 }
749
750 settingsRoutePresentedRef.current = true;
751 navigation.dispatch(StackActions.push("ThreadSettingsSheet"));
752 }, [navigation, settingsSheetPresentation.isVisible]);
753
754 useFocusEffect(
755 useCallback(() => {
756 if (!settingsRoutePresentedRef.current) {
757 return;
758 }
759
760 settingsRoutePresentedRef.current = false;
761 settingsSheetPresentation.onDismissed();
762 settingsRoutePresentation.clear(settingsOwnerId);
763 }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]),
764 );
765
766 useEffect(
767 () =>
768 // UIKit's completion callback for the sheet dismissal, surfaced by the
769 // native-stack patch. This is when the queued keyboard restore runs.
770 (navigation as unknown as NavigationWithFinishTransitioning).addListener(
771 "finishTransitioning",
772 settingsSheetPresentation.onStackTransitionsFinished,
773 ),
774 [navigation, settingsSheetPresentation.onStackTransitionsFinished],
775 );
776
777 return (
778 <Animated.View
779 className="px-[12px]"
780 style={{
781 paddingTop: isExpanded ? 8 : 6,
782 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
783 backgroundColor:
784 Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined,
785 }}
786 >
787 {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View
788 silently drops experimental_backgroundImage on Android, which left this
789 strip fully transparent and the feed text legible through the composer. */}
790 <View
791 className={
792 Platform.OS === "android"
793 ? "hidden"
794 : "absolute inset-0 bg-linear-to-b from-screen/0 via-screen/60 to-screen/90"
795 }
796 pointerEvents="none"
797 />
798 <Animated.View
799 className="relative w-full self-center"
800 style={{ maxWidth: props.contentMaxWidth }}
801 >
802 {!voiceInput.isBusy &&
803 composerMenu.trigger &&
804 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
805 <ComposerPopoverAnchor>
806 <ComposerCommandPopover
807 items={composerMenu.items}
808 triggerKind={composerMenu.trigger.kind}
809 isLoading={composerMenu.isLoading}
810 error={composerMenu.error}
811 onSelect={composerMenu.onSelect}
812 />
813 </ComposerPopoverAnchor>
814 ) : null}
815
816 {selectedProviderStatus?.compatibilityAdvisory?.message &&
817 (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" ||
818 selectedProviderStatus.compatibilityAdvisory.status === "broken") ? (
819 <Text
820 accessibilityRole={
821 selectedProviderStatus.compatibilityAdvisory.status === "broken" ? "alert" : undefined
822 }
823 accessibilityLiveRegion={
824 selectedProviderStatus.compatibilityAdvisory.status === "broken"
825 ? "assertive"
826 : "polite"
827 }
828 className={
829 selectedProviderStatus.compatibilityAdvisory.status === "broken"
830 ? "bg-danger px-3 py-2 text-xs text-danger-foreground"
831 : "px-3 py-2 text-xs text-foreground"
832 }
833 >
834 {selectedProviderStatus.compatibilityAdvisory.message}
835 </Text>
836 ) : null}
837 {modelUnavailable ? (
838 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
839 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
840 </Pressable>
841 ) : null}
842
843 <ComposerSurface
844 style={
845 isExpanded
846 ? {
847 borderRadius: 26,
848 minHeight: 140,
849 overflow: "hidden" as const,
850 paddingBottom: 6,
851 paddingTop: 14,
852 }
853 : {
854 // Keep the numeric radius close to the expanded card so the
855 // shape morph stays bounded while rendering as a capsule.
856 borderRadius: 27,
857 overflow: "hidden" as const,
858 paddingVertical: 2,
859 }
860 }
861 >
862 <ComposerDictationDraftContent
863 className={isExpanded ? undefined : "flex-row items-center"}
864 compact={!isExpanded}
865 hidden={showsCompactDictation}
866 >
867 {!isExpanded ? (
868 <ComposerAttachmentButton
869 supportsFiles={Boolean(
870 props.serverConfig?.environment.capabilities.fileAttachments,
871 )}
872 onPickMedia={props.onPickDraftMedia}
873 onPickFiles={props.onPickDraftFiles}
874 />
875 ) : null}
876 {isExpanded && queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? (
877 <Animated.View
878 className="px-[14px] pb-2.5"
879 entering={COMPOSER_ATTACHMENT_ENTERING}
880 exiting={FadeOut.duration(120)}
881 >
882 <ComposerQueuedEditAttachments
883 environmentId={props.environmentId}
884 attachments={queuedEdit.existingAttachments}
885 disabled={queuedEdit.saving || voiceInput.isBusy}
886 onRemove={queuedEdit.onRemoveExistingAttachment}
887 />
888 </Animated.View>
889 ) : null}
890 {isExpanded && stripAttachments.length > 0 ? (
891 <Animated.View
892 className="px-[14px] pb-2.5"
893 entering={COMPOSER_ATTACHMENT_ENTERING}
894 exiting={FadeOut.duration(120)}
895 >
896 <ComposerAttachmentStrip
897 environmentId={props.environmentId}
898 attachments={stripAttachments}
899 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
900 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
901 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
902 onPressDocument={
903 voiceInput.isBusy
904 ? undefined
905 : (attachment) =>
906 openDraftDocument({
907 attachmentId: attachment.id,
908 name: attachment.name,
909 mimeType: attachment.mimeType,
910 sizeBytes: attachment.sizeBytes,
911 })
912 }
913 />
914 </Animated.View>
915 ) : null}
916 <Animated.View
917 className={isExpanded ? "px-[14px]" : "min-w-0 flex-1 px-[4px]"}
918 layout={COMPOSER_LAYOUT_TRANSITION}
919 >
920 <ComposerEditor
921 draftKey={composerDraftKey}
922 environmentId={props.environmentId}
923 onOpenMention={(path) => {
924 Keyboard.dismiss();
925 navigation.navigate("ThreadFile", {
926 environmentId: String(props.environmentId),
927 threadId: String(props.selectedThread.id),
928 path: fileRoutePathSegments(path),
929 });
930 }}
931 onOpenAttachment={openDraftDocument}
932 // A rested composer full of chips left almost nowhere to tap to start typing:
933 // every chip opened its file instead. Collapsed, they focus the editor.
934 chipsInert={!isExpanded}
935 onInertChipPress={() => inputRef.current?.focus()}
936 ref={inputRef}
937 multiline
938 value={props.draftMessage}
939 readOnly={voiceInput.freezesEditor}
940 skills={composerMenu.skills}
941 selection={composerMenu.selection}
942 onChangeText={props.onChangeDraftMessage}
943 onSelectionChange={composerMenu.onSelectionChange}
944 onPasteImages={(uris) => void props.onNativePasteImages(uris)}
945 onPasteText={(paste) => {
946 const insertPaste = () => {
947 const insertion = replaceTextSelection({
948 value: paste.value,
949 selection: paste.selection,
950 text: paste.text,
951 });
952 const selection = { start: insertion.cursor, end: insertion.cursor };
953 props.onChangeDraftMessage(insertion.value);
954 composerMenu.onSelectionChange(selection);
955 };
956 const capabilities = props.serverConfig?.environment.capabilities;
957 const advertisedMax =
958 capabilities?.attachmentUploads === true
959 ? capabilities.fileAttachments?.maxUploadBytes
960 : undefined;
961 const maxBytes =
962 advertisedMax === undefined
963 ? null
964 : clampFileAttachmentUploadBytes(advertisedMax);
965 const wouldExceedInputLimit =
966 paste.value.length -
967 Math.max(0, paste.selection.end - paste.selection.start) +
968 paste.text.length >
969 PROVIDER_SEND_TURN_MAX_INPUT_CHARS;
970 const canAttach =
971 maxBytes !== null &&
972 countComposerDraftAttachmentsAfterSelection(composerDraftKey, {
973 text: paste.value,
974 ...paste.selection,
975 }) < PROVIDER_SEND_TURN_MAX_ATTACHMENTS &&
976 new TextEncoder().encode(paste.text).byteLength <= maxBytes;
977 if (
978 pastedTextDisposition({
979 text: paste.text,
980 wouldExceedInputLimit,
981 canAttach: true,
982 }) === "attachment"
983 ) {
984 if (canAttach) {
985 pendingPastedTextAttachmentCountRef.current += 1;
986 setPendingPastedTextAttachmentCount(
987 pendingPastedTextAttachmentCountRef.current,
988 );
989 const finishAttachment = () => {
990 pendingPastedTextAttachmentCountRef.current = Math.max(
991 0,
992 pendingPastedTextAttachmentCountRef.current - 1,
993 );
994 setPendingPastedTextAttachmentCount(
995 pendingPastedTextAttachmentCountRef.current,
996 );
997 };
998 void props.onNativePasteText(paste).then(finishAttachment, finishAttachment);
999 } else if (!wouldExceedInputLimit) {
1000 insertPaste();
1001 } else {
1002 Alert.alert(
1003 wouldExceedInputLimit
1004 ? "Pasted text is too large for this message"
1005 : "Could not attach pasted text",
1006 wouldExceedInputLimit
1007 ? "Remove some text or an attachment, then paste again."
1008 : "Remove an attachment or use a smaller paste, then try again.",
1009 );
1010 }
1011 return;
1012 }
1013 insertPaste();
1014 }}
1015 placeholder={props.placeholder}
1016 onFocus={handleFocus}
1017 onBlur={handleBlur}
1018 // Command-Return sends the other way, matching web's Mod+Enter.
1019 onSubmit={(alternate) =>
1020 void handleSend(
1021 alternate && sendPresentation.alternate !== null
1022 ? sendPresentation.alternate
1023 : undefined,
1024 )
1025 }
1026 submitTitle={sendPresentation.label}
1027 alternateSubmitTitle={
1028 sendPresentation.alternate === null
1029 ? sendPresentation.label
1030 : FOLLOW_UP_ACTION_LABEL[sendPresentation.alternate]
1031 }
1032 scrollEnabled={isExpanded}
1033 // Android: collapsed single line centers natively (gravity) in
1034 // a pill-height box matching the send button; iOS keeps insets.
1035 singleLineCentered={!isExpanded}
1036 contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6}
1037 style={
1038 isExpanded
1039 ? {
1040 minHeight: 72,
1041 maxHeight: 160,
1042 paddingVertical: 4,
1043 }
1044 : {
1045 height: 36,
1046 }
1047 }
1048 textStyle={{
1049 ...bodyText,
1050 color: foregroundColor,
1051 }}
1052 />
1053 </Animated.View>
1054 {!isExpanded && stripAttachments.length > 0 ? (
1055 <View className="flex-row gap-1 pl-1">
1056 {stripAttachments.slice(0, 3).map((attachment) => (
1057 <ComposerAttachmentThumbnail
1058 environmentId={props.environmentId}
1059 key={attachment.id}
1060 attachment={attachment}
1061 size={30}
1062 borderRadius={8}
1063 compact
1064 onPressPreview={onPressPreview}
1065 onPressVideo={onPressVideo}
1066 />
1067 ))}
1068 {stripAttachments.length > 3 ? (
1069 <View className="size-[30px] items-center justify-center rounded-lg bg-subtle-strong">
1070 <Text className="text-foreground-muted text-2xs font-t3-bold">
1071 +{stripAttachments.length - 3}
1072 </Text>
1073 </View>
1074 ) : null}
1075 </View>
1076 ) : null}
1077 {!isExpanded ? (
1078 <View className="flex-row items-center">
1079 <ComposerDictationStartAction
1080 state={voiceInput.state}
1081 isAvailable={voiceInput.isAvailable}
1082 onStart={voiceInput.start}
1083 onCancel={voiceInput.cancel}
1084 />
1085 {showStopAction ? (
1086 <ComposerActionButton
1087 accessibilityLabel="Stop agent"
1088 icon="stop.fill"
1089 variant="danger"
1090 disabled={!props.canOperateThread}
1091 onPress={props.onStopThread}
1092 />
1093 ) : (
1094 <SendActionButton
1095 accessibilityLabel={sendBlockedReason ?? sendLabel}
1096 presentation={sendPresentation}
1097 disabled={!canSend}
1098 onSend={handleSend}
1099 />
1100 )}
1101 </View>
1102 ) : null}
1103 {isExpanded ? <View className="h-1" /> : null}
1104 </ComposerDictationDraftContent>
1105 <Animated.View
1106 accessibilityElementsHidden={!isToolbarVisible}
1107 collapsable={false}
1108 importantForAccessibility={isToolbarVisible ? "auto" : "no-hide-descendants"}
1109 layout={COMPOSER_LAYOUT_TRANSITION}
1110 pointerEvents={isToolbarVisible ? "auto" : "none"}
1111 style={
1112 isExpanded
1113 ? undefined
1114 : {
1115 position: "absolute",
1116 bottom: 2,
1117 left: 0,
1118 right: 0,
1119 }
1120 }
1121 >
1122 <ComposerDictationToolbar
1123 showsDictation={isVoiceInputPresented}
1124 visible={isToolbarVisible}
1125 >
1126 <ComposerToolbarRow
1127 paddingBottom={0}
1128 paddingHorizontal={0}
1129 paddingTop={0}
1130 style={{ gap: 0 }}
1131 >
1132 <ComposerDictationCancelAction
1133 presentation={voicePresentation}
1134 onCancel={voiceInput.cancel}
1135 />
1136 {isVoiceInputPresented ? (
1137 <ComposerDictationStatus
1138 audioLevels={voiceInput.audioLevels}
1139 elapsedSeconds={voiceInput.elapsedSeconds}
1140 phase={voiceInput.state.phase}
1141 presentation={voicePresentation}
1142 onDismissError={voiceInput.cancel}
1143 />
1144 ) : (
1145 <View className="min-w-0 flex-1 flex-row items-center justify-between">
1146 <ComposerAttachmentButton
1147 supportsFiles={Boolean(
1148 props.serverConfig?.environment.capabilities.fileAttachments,
1149 )}
1150 onPickMedia={props.onPickDraftMedia}
1151 onPickFiles={props.onPickDraftFiles}
1152 />
1153 <View className="min-w-0 shrink">
1154 <ComposerInlineControl
1155 accessibilityLabel="Model and reasoning settings"
1156 emphasized
1157 renderIcon={(size) => (
1158 <ProviderIcon
1159 iconUrl={currentModelOption?.providerIconUrl}
1160 provider={currentModelOption?.providerDriver}
1161 size={size}
1162 />
1163 )}
1164 label={currentModelOption?.label ?? currentModelSelection.model}
1165 maxWidth="100%"
1166 onPress={openSettings}
1167 />
1168 </View>
1169 </View>
1170 )}
1171 <View className="shrink-0 flex-row items-center">
1172 <ComposerDictationPrimaryAction
1173 state={voiceInput.state}
1174 presentation={voicePresentation}
1175 isAvailable={voiceInput.isAvailable}
1176 onStart={voiceInput.start}
1177 onConfirm={voiceInput.stop}
1178 onCancel={voiceInput.cancel}
1179 />
1180 {showStopAction ? (
1181 <ComposerActionButton
1182 accessibilityLabel="Stop agent"
1183 icon="stop.fill"
1184 variant="danger"
1185 disabled={!props.canOperateThread}
1186 onPress={props.onStopThread}
1187 />
1188 ) : voicePresentation.showsSend ? (
1189 <SendActionButton
1190 accessibilityLabel={sendBlockedReason ?? sendLabel}
1191 presentation={sendPresentation}
1192 disabled={!canSend}
1193 onSend={handleSend}
1194 />
1195 ) : null}
1196 </View>
1197 </ComposerToolbarRow>
1198 </ComposerDictationToolbar>
1199 </Animated.View>
1200 </ComposerSurface>
1201
1202 {props.connectionState === "connected" && !props.canOperateThread ? (
1203 <Text className="pt-2 text-xs text-foreground-muted">
1204 This connection cannot control this task. You can still edit your draft.
1205 </Text>
1206 ) : null}
1207 </Animated.View>
1208
1209 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
1210 <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
1211 </Animated.View>
1212 );
1213});