import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";import { useAtomValue } from "@effect/atom-react";import type { EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, UsageLimitsReport,} from "@t3tools/contracts";import { collectProviderUsageLimits, hasProviderUsageLimits, isUsageLimitsCommand,} from "@t3tools/shared/usageLimits";import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";import type { ReactNode } from "react";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject,} from "react";import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native";import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, composerAttachmentUploadsAtom,} from "../../state/composer-attachment-uploads";import Animated, { FadeIn, FadeOut, type LayoutAnimationFunction, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";import { useUniwindTheme } from "../../lib/useUniwindTheme";import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText";import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";import { ComposerAttachmentStrip, ComposerAttachmentThumbnail,} from "../../components/ComposerAttachmentStrip";import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";import { GlassSurface } from "../../components/GlassSurface";import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow,} from "../../components/ComposerToolbar";import { ProviderIcon } from "../../components/ProviderIcon";import type { DraftComposerAttachment, DraftComposerFileAttachment,} from "../../lib/composerImages";import { buildModelOptions, groupByProvider, isModelSelectionUnavailable,} from "../../lib/modelOptions";import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";import type { RemoteClientConnectionState } from "../../lib/connection";import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";import { ComposerCommandPopover } from "./ComposerCommandPopover";import { useComposerCommandMenu } from "./use-composer-command-menu";import { ComposerDictationCancelAction, ComposerDictationDraftContent, ComposerDictationPrimaryAction, ComposerDictationStartAction, ComposerDictationStatus, ComposerDictationToolbar,} from "../voice-input/ComposerDictationControl";import { useVoiceInputController } from "../voice-input/useVoiceInputController";import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation";import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation,} from "./ThreadSettingsSheet";import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning,} from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; readonly selectedThread: OrchestrationThreadShell; readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; /** Why sending is blocked right now (shown as the send button's label), or null. */ readonly sendBlockedReason?: string | null; readonly editorRef?: RefObject<ComposerEditorHandle | null>; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise<void>; readonly onPickDraftFiles: () => Promise<void>; readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise<MessageId | null>; /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void;} /** * The pill / card container — renders with Expo's native GlassView on supported * iOS 26+ devices, with a frosted blur fallback where supported. * Exported so NewTaskDraftScreen can render the same composer chrome. */// The bottom-anchored dock position and clipped surface height use the same// transition so the card grows upward without exposing its final-size content.// Android gets NO layout transition: the composer rides the keyboard via// KeyboardStickyView (frame-synced to the IME), and a time-based morph// running alongside that translate reads as jitter. Snapping the layout and// letting the keyboard-synced slide be the only motion looks native there.export const COMPOSER_TRANSITION_DURATION_MS = 220;// Side panes already animate the dock's width. Nested horizontal layout// transitions would leave the surface trailing its toolbar's new position.// Keep the vertical pill/card morph while horizontal layout follows the dock.const composerHeightTransition: LayoutAnimationFunction = (values) => { "worklet"; const timing = { duration: COMPOSER_TRANSITION_DURATION_MS, reduceMotion: ReduceMotion.System, }; return { initialValues: { originX: values.targetOriginX, originY: values.currentOriginY, width: values.targetWidth, height: values.currentHeight, }, animations: { originX: values.targetOriginX, originY: withTiming(values.targetOriginY, timing), width: values.targetWidth, height: withTiming(values.targetHeight, timing), }, };};export const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" ? FadeIn.duration(160) : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean;}) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const colors = useUniwindTheme(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; const animatedBorderRadius = useSharedValue(targetBorderRadius); const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; useLayoutEffect(() => { animatedBorderRadius.value = shouldAnimate ? withTiming(targetBorderRadius, { duration: COMPOSER_TRANSITION_DURATION_MS, reduceMotion: ReduceMotion.System, }) : targetBorderRadius; }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); const animatedShapeStyle = useAnimatedStyle(() => ({ borderRadius: animatedBorderRadius.value, })); const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined; // Each native frame follows the same transition. Animating only the outer // clip leaves the glass and content at their final height on the first frame. return ( <Animated.View className={ materialYouStyleLayoutActive ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" } layout={layoutTransition} style={[ animatedShapeStyle, { overflow: "hidden", // Android versions before 9 do not support outset box shadows. elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, }, ]} > <AnimatedGlassSurface chrome="none" fallbackColor={ materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"] } fallbackClassName={ materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border" } glassEffectStyle="regular" // The composer is a passive material containing interactive controls. // Keep native glass out of the interactive content's layout path. pointerEvents="none" tintColor="transparent" layout={layoutTransition} style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]} > {null} </AnimatedGlassSurface> <Animated.View collapsable={false} layout={layoutTransition} style={[props.style, animatedShapeStyle]} > {props.children} </Animated.View> </Animated.View> );} export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const { materialYouStyleLayoutActive, themeVariables: materialTheme } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef<ComposerEditorHandle>(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, }); const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set<string>()); const { onExpandedChange } = props; const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = !hasContent && (props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"); const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentsUploading = props.connectionState === "connected" && composerAttachmentsStillUploading({ environmentId: props.environmentId, attachments: props.draftAttachments, serverConfig: props.serverConfig, states: uploadStates, }); // Every send goes through the outbox; the label says whether it leaves now // or waits (for the connection, an earlier queued message, or an upload). const sendLabel = props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const modelUnavailable = props.connectionState === "connected" && isModelSelectionUnavailable(props.serverConfig, currentModelSelection); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( props.serverConfig.providers.find( (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; // T3 owns /usage-limits only where Limits has data for the selected provider; // elsewhere the name stays the provider's own and is sent through untouched. const usageLimitsOffered = selectedProviderStatus !== null && hasProviderUsageLimits( selectedProviderStatus.driver, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], ); // Answered locally from the last Limits snapshot; the agent never sees it. const openUsageLimits = useCallback(() => { const report = collectProviderUsageLimits( currentModelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], Date.now(), ); onShowUsageLimits(report); if (!report) { Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); } return report !== null; }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, ownerKey: composerOwnerKey, environmentId: props.environmentId, projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, offersUsageLimits: usageLimitsOffered, // With attachments aboard the pick just inserts the text, so it sends as a prompt. onUsageLimits: usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, draftMessage: props.draftMessage, selection: composerMenu.selection, onChangeDraftMessage: props.onChangeDraftMessage, onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( voiceInput.state, voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: props.draftAttachments, connected: props.connectionState === "connected", serverConfig: props.serverConfig, states: uploadStates, }); const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; const canSend = hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); const onPressPreview = useCallback( (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewVideo(null); setPreviewFile((current) => current ?? source); }, [isFocused], ); const closePreview = useCallback(() => { setPreviewFile(null); setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) inputRef.current?.focus(); }, 100); } }, [inputRef, navigation]); const onPressVideo = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isFocused], ); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); onExpandedChange?.(true); onEditorFocusChange?.(true); }, [onEditorFocusChange, onExpandedChange]); const handleBlur = useCallback(() => { setIsFocused(false); if (!settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( usageLimitsOffered && isUsageLimitsCommand(props.draftMessage) && props.draftAttachments.length === 0 ) { if (openUsageLimits()) onChangeDraftMessage(""); return; } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { const messageId = await onSendMessage(); if (messageId === null) { return; } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ props.draftMessage, props.draftAttachments.length, onChangeDraftMessage, openUsageLimits, usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, voiceInput.blocksSubmission, ]); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( () => buildModelOptions(props.serverConfig, currentModelSelection), [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); // An existing thread is bound to its harness: sessions can't move between // provider instances, so the picker only offers the thread's own group. const threadProviderGroups = useMemo( () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), [providerGroups, currentModelSelection.instanceId], ); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null;Lines 1–500 of 855. Load more
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, UsageLimitsReport, } from "@t3tools/contracts"; import { collectProviderUsageLimits, hasProviderUsageLimits, isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject, } from "react"; import { Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, composerAttachmentUploadsAtom, } from "../../state/composer-attachment-uploads"; import Animated, { FadeIn, FadeOut, type LayoutAnimationFunction, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip, ComposerAttachmentThumbnail, } from "../../components/ComposerAttachmentStrip"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow, } from "../../components/ComposerToolbar"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerAttachment, DraftComposerFileAttachment, } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider, isModelSelectionUnavailable, } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { ComposerDictationCancelAction, ComposerDictationDraftContent, ComposerDictationPrimaryAction, ComposerDictationStartAction, ComposerDictationStatus, ComposerDictationToolbar, } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, } from "./use-thread-settings-sheet-presentation"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). * Exported so the parent can compute feed overlap / content insets. */ export const COMPOSER_COLLAPSED_CHROME = 60; /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. */ export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; readonly selectedThread: OrchestrationThreadShell; readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; /** Why sending is blocked right now (shown as the send button's label), or null. */ readonly sendBlockedReason?: string | null; readonly editorRef?: RefObject<ComposerEditorHandle | null>; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise<void>; readonly onPickDraftFiles: () => Promise<void>; readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise<MessageId | null>; /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void; } /** * The pill / card container — renders with Expo's native GlassView on supported * iOS 26+ devices, with a frosted blur fallback where supported. * Exported so NewTaskDraftScreen can render the same composer chrome. */ // The bottom-anchored dock position and clipped surface height use the same // transition so the card grows upward without exposing its final-size content. // Android gets NO layout transition: the composer rides the keyboard via // KeyboardStickyView (frame-synced to the IME), and a time-based morph // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. export const COMPOSER_TRANSITION_DURATION_MS = 220; // Side panes already animate the dock's width. Nested horizontal layout // transitions would leave the surface trailing its toolbar's new position. // Keep the vertical pill/card morph while horizontal layout follows the dock. const composerHeightTransition: LayoutAnimationFunction = (values) => { "worklet"; const timing = { duration: COMPOSER_TRANSITION_DURATION_MS, reduceMotion: ReduceMotion.System, }; return { initialValues: { originX: values.targetOriginX, originY: values.currentOriginY, width: values.targetWidth, height: values.currentHeight, }, animations: { originX: values.targetOriginX, originY: withTiming(values.targetOriginY, timing), width: values.targetWidth, height: withTiming(values.targetHeight, timing), }, }; }; export const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" ? FadeIn.duration(160) : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const colors = useUniwindTheme(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; const animatedBorderRadius = useSharedValue(targetBorderRadius); const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; useLayoutEffect(() => { animatedBorderRadius.value = shouldAnimate ? withTiming(targetBorderRadius, { duration: COMPOSER_TRANSITION_DURATION_MS, reduceMotion: ReduceMotion.System, }) : targetBorderRadius; }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); const animatedShapeStyle = useAnimatedStyle(() => ({ borderRadius: animatedBorderRadius.value, })); const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined; // Each native frame follows the same transition. Animating only the outer // clip leaves the glass and content at their final height on the first frame. return ( <Animated.View className={ materialYouStyleLayoutActive ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35" } layout={layoutTransition} style={[ animatedShapeStyle, { overflow: "hidden", // Android versions before 9 do not support outset box shadows. elevation: Platform.OS === "android" && Platform.Version < 28 ? 10 : undefined, }, ]} > <AnimatedGlassSurface chrome="none" fallbackColor={ materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"] } fallbackClassName={ materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border" } glassEffectStyle="regular" // The composer is a passive material containing interactive controls. // Keep native glass out of the interactive content's layout path. pointerEvents="none" tintColor="transparent" layout={layoutTransition} style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]} > {null} </AnimatedGlassSurface> <Animated.View collapsable={false} layout={layoutTransition} style={[props.style, animatedShapeStyle]} > {props.children} </Animated.View> </Animated.View> ); } export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const { materialYouStyleLayoutActive, themeVariables: materialTheme } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef<ComposerEditorHandle>(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, isEditorFocused: isFocused, }); const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation(); const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set<string>()); const { onExpandedChange } = props; const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null); const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = !hasContent && (props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"); const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentsUploading = props.connectionState === "connected" && composerAttachmentsStillUploading({ environmentId: props.environmentId, attachments: props.draftAttachments, serverConfig: props.serverConfig, states: uploadStates, }); // Every send goes through the outbox; the label says whether it leaves now // or waits (for the connection, an earlier queued message, or an upload). const sendLabel = props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const modelUnavailable = props.connectionState === "connected" && isModelSelectionUnavailable(props.serverConfig, currentModelSelection); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( props.serverConfig.providers.find( (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; // T3 owns /usage-limits only where Limits has data for the selected provider; // elsewhere the name stays the provider's own and is sent through untouched. const usageLimitsOffered = selectedProviderStatus !== null && hasProviderUsageLimits( selectedProviderStatus.driver, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], ); // Answered locally from the last Limits snapshot; the agent never sees it. const openUsageLimits = useCallback(() => { const report = collectProviderUsageLimits( currentModelSelection.instanceId, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], Date.now(), ); onShowUsageLimits(report); if (!report) { Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); } return report !== null; }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, ownerKey: composerOwnerKey, environmentId: props.environmentId, projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, offersUsageLimits: usageLimitsOffered, // With attachments aboard the pick just inserts the text, so it sends as a prompt. onUsageLimits: usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, draftMessage: props.draftMessage, selection: composerMenu.selection, onChangeDraftMessage: props.onChangeDraftMessage, onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( voiceInput.state, voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: props.draftAttachments, connected: props.connectionState === "connected", serverConfig: props.serverConfig, states: uploadStates, }); const sendBlockedReason = props.sendBlockedReason ?? attachmentBlockReason; const canSend = hasContent && !voiceInput.blocksSubmission && sendBlockedReason === null && !modelUnavailable; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); const onPressPreview = useCallback( (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewVideo(null); setPreviewFile((current) => current ?? source); }, [isFocused], ); const closePreview = useCallback(() => { setPreviewFile(null); setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) inputRef.current?.focus(); }, 100); } }, [inputRef, navigation]); const onPressVideo = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isFocused], ); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); onExpandedChange?.(true); onEditorFocusChange?.(true); }, [onEditorFocusChange, onExpandedChange]); const handleBlur = useCallback(() => { setIsFocused(false); if (!settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. if ( usageLimitsOffered && isUsageLimitsCommand(props.draftMessage) && props.draftAttachments.length === 0 ) { if (openUsageLimits()) onChangeDraftMessage(""); return; } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { const messageId = await onSendMessage(); if (messageId === null) { return; } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't // contend with the queued-message feedback on the tap frame. armAgentAwarenessLiveActivityForLocalWork({ environmentId: props.environmentId, threadTitle: props.selectedThread.title, projectTitle: props.environmentLabel ?? "T3 Code", }); } finally { inFlightThreadIdsRef.current.delete(threadKey); } }, [ props.draftMessage, props.draftAttachments.length, onChangeDraftMessage, openUsageLimits, usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, props.selectedThread.id, props.selectedThread.title, voiceInput.blocksSubmission, ]); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( () => buildModelOptions(props.serverConfig, currentModelSelection), [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); // An existing thread is bound to its harness: sessions can't move between // provider instances, so the picker only offers the thread's own group. const threadProviderGroups = useMemo( () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), [providerGroups, currentModelSelection.instanceId], ); const currentModelOption = modelOptions.find( (option) => option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null;