Composer
ComposerView is where the user writes to your agent. It's a text field that grows with its lines, photos from the library and the camera with previews, dictation through the platform's speech recognition, a sheet of chat options such as "Think harder", and a send button that turns into a stop button while the agent answers. It rides the keyboard up, and hands you what the user wrote when they send it:
import { View } from "react-native";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import {
type AIConversationController,
ComposerView,
} from "stream-chat-react-native/ai-components";
export const Assistant = ({
conversation,
}: {
conversation: AIConversationController;
}) => (
<View style={{ flex: 1 }}>
<AIConversation conversation={conversation} />
<ComposerView conversation={conversation} />
</View>
);ComposerView is available from version 9.10.0 of stream-chat-react-native and stream-chat-expo, in the stream-chat-react-native/ai-components and stream-chat-expo/ai-components entry points, and its chat options, header and trailing from 9.13.0, and conversation from 9.14.0. See the Quickstart to install it. Render it inside StreamTheme, like the other AI components.
Send what the user writes
Give the composer either a conversation, an AIConversationController, or an onSendMessage. With a controller, the composer sends to it, shows the stop button while the agent answers, and shows an "Editing message" chip while the user edits a message. It takes the place of onSendMessage, isGenerating, stopGenerating and state.
| Name | Type | Required | Description |
|---|---|---|---|
conversation |
AIConversationController |
no | The conversation the user writes in. Required without onSendMessage. |
onSendMessage |
(opts: { text: string; attachments?: MediaPickerState['assets']; chatOption?: AIChatOption; custom?: Record<string, unknown>; }) => Promise<void> |
no | Called when the send button is pressed. Required without conversation. It gets the text, the attachments the user picked, the picked chat option and any custom data in the composer's state. |
chatOptions |
AIChatOption[] |
no | Ways to ask, such as "Think harder", listed in the sheet. See Chat options. |
header |
ReactNode |
no | Shown above the composer, moving with it, such as AISuggestions for an empty conversation, or an error with a way to try again. |
trailing |
ReactNode |
no | Shown after the message box, on the same line, such as a button into voice mode. |
keyboardOffset |
number |
no | How far above the bottom of the screen the composer sits, such as above a tab bar. The composer rides the keyboard up by as much as the keyboard rises past that. Defaults to the bottom safe area. |
bottomSheetOptions |
BottomSheetOption[] |
no | Extra options in the sheet. See Add your own options. |
bottomSheetInsets |
{ top: number; bottom: number; left: number; right: number } |
no | Insets the sheet extends beyond, such as the safe area. |
SpeechToTextButton |
ComponentType | null |
no | The button that dictates into the message box while it is empty. Defaults to SpeechToTextButton. null shows none. |
isGenerating |
boolean |
no | Whether the agent is answering. The composer shows the stop button in place of the send button while it is. Not needed with conversation. |
stopGenerating |
() => Promise<void> |
no | Called when the stop button is pressed. Not needed with conversation. |
mediaPickerService |
AbstractMediaPickerService |
no | A MediaPickerService of your own, for finer control over the picked photos. new MediaPickerService() finds the library you installed. |
state |
StateStore<ComposerState> |
no | A store of the composer's state of your own, for finer control over it. createNewComposerStore() makes one. See Control it from your code. |
Without a controller, send through your own function and tell the composer when the agent is answering:
<ComposerView
isGenerating={isAnswering}
onSendMessage={async ({ attachments, text }) => ask(text, attachments)}
stopGenerating={stop}
/>The send button shows once the text field has text. Until then, the microphone button takes its place. With Stream Chat, the composer can send through a channel's own composer, as SDK integration shows.
The keyboard
ComposerView rides the keyboard with react-native-keyboard-controller, when it's installed and its KeyboardProvider is above the composer. Without the library, the composer doesn't move with the keyboard, so keep it in view yourself, for example with a KeyboardAvoidingView.
AIConversation handles the keyboard the same way, and both take a keyboardOffset, so give both the same value when you change it. The keyboard describes how they work together.
Attach photos
The composer's sheet opens from the attach button before the text field. It lists the camera and the photo library, then your own options. Photos need one of two libraries, which the composer finds by itself:
-
react-native-image-picker, for React Native CLI apps:yarn add react-native-image-pickerTo take photos, add the camera permission to
Info.plistandAndroidManifest.xml:<key>NSCameraUsageDescription</key> <string>$(PRODUCT_NAME) would like to use your camera to share an image in a message.</string><uses-permission android:name="android.permission.CAMERA" /> -
expo-image-picker, for Expo apps:npx expo install expo-image-pickerAdd its config plugin with the
photosPermissionandcameraPermissiontexts, as Expo's documentation describes.
Without either library, the sheet has no camera or photos. Up to three photos can be picked at once. The previews show above the text field, each with a button that removes it, and the picked photos reach onSendMessage as attachments, or the controller's transport as request.attachments: files on the device, for you to upload or to send to the agent. The Personal assistant use case sends them to the agent as data URIs.
Chat options
chatOptions adds ways to ask, such as Think harder, to the composer's sheet, under the camera and photos. The one picked shows as a chip above the input, which the user can remove, and it's passed to onSendMessage as chatOption with every message until they do. With a controller, it reaches the transport as the request's chatOption:
import {
type AIChatOption,
ComposerView,
} from "stream-chat-react-native/ai-components";
const thinkHarder: AIChatOption = {
id: "think_harder",
title: "Think harder",
description: "Takes longer, for harder questions",
shortTitle: "Think",
};
<ComposerView
chatOptions={[thinkHarder]}
onSendMessage={async ({ chatOption, text }) =>
ask(text, {
reasoningEffort: chatOption?.id === thinkHarder.id ? "high" : undefined,
})
}
/>;An AIChatOption has an id, a title, and an optional description, icon, shortTitle for the chip, and customData of your own. Picking the option that's already picked removes it. ChatOptionsList and SelectedChatOptionChip draw the menu and the chip, for a composer of your own.
Add your own options
bottomSheetOptions adds actions of your own to the sheet. Each has a title, an action, and optionally a subtitle and an Icon. The sheet draws the Icon without props, so give it its size and color:
import { Alert } from "react-native";
import { Flag, Picture } from "stream-chat-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const options = [
{
title: "Create Image",
subtitle: "Visualize anything",
action: () => Alert.alert("Pressed on Create Image !"),
Icon: () => <Picture size={20} stroke="#1a1b25" />,
},
{
title: "Thinking",
subtitle: "Think longer for better answers",
action: () => Alert.alert("Pressed on Thinking !"),
Icon: () => <Flag size={20} stroke="#1a1b25" />,
},
];
const insets = useSafeAreaInsets();
<ComposerView
bottomSheetInsets={insets}
bottomSheetOptions={options}
conversation={conversation}
/>;Pass the safe area insets as bottomSheetInsets, so the sheet can extend beyond them.
Control it from your code
The composer keeps what the user writes in a store, a StateStore<ComposerState>. Inside the composer, these hooks read it:
| Hook | Returns |
|---|---|
useComposerText() |
text, what the user has written. |
useComposerHasText() |
hasText, whether there is any. |
useComposerChatOption() |
chatOption, the chat option picked, or null. |
useComposerIsEditing() |
isEditing, whether the user is editing a message they sent. |
To change the state from outside, give the composer a store of your own, and write to it:
import {
ComposerView,
createNewComposerStore,
} from "stream-chat-react-native/ai-components";
const composer = createNewComposerStore();
// A button that fills in a prompt.
composer.partialNext({ text: "Summarize my week" });
<ComposerView onSendMessage={send} state={composer} />;ComposerState has the text, the chatOption, isEditing and custom, data of your own that reaches onSendMessage, or the transport's request.custom. A controller makes a store of its own, which conversation.composer gives you, or takes yours as its composer option.
Stop the reply
With a controller, the stop button shows by itself while the agent answers, and calls the transport's stop. Without one, show it with isGenerating and stopGenerating. With a Stream Chat channel, read the channel's AI state with useAIState, and stop the reply with channel.stopAIResponse(), which sends ai_indicator.stop to the channel:
import { useCallback } from "react";
import {
AIStates,
useAIState,
useChannelContext,
} from "stream-chat-react-native";
import {
ComposerView,
type ComposerViewProps,
} from "stream-chat-react-native/ai-components";
const activeStates = [
AIStates.Thinking,
AIStates.ExternalSources,
AIStates.Generating,
];
export const AssistantComposer = ({
send,
}: {
send: ComposerViewProps["onSendMessage"];
}) => {
const { channel } = useChannelContext();
const { aiState } = useAIState(channel);
const stop = useCallback(() => channel.stopAIResponse(), [channel]);
return (
<ComposerView
isGenerating={activeStates.includes(aiState)}
onSendMessage={send}
stopGenerating={stop}
/>
);
};Render it inside Channel. The button doesn't hide itself when pressed: it goes once the backend's ai_indicator.clear reaches the channel and moves aiState out of the active states. Include AIStates.ExternalSources, as above, since the agent only calls tools in that state, and the stop button would go while a tool runs.
The default MessageComposer of the Chat SDK has a stop button of its own. It shows only while aiState is AI_STATE_THINKING or AI_STATE_GENERATING, not during AI_STATE_EXTERNAL_SOURCES.
Stop the reply covers what the backend does with the event.
Dictation
The microphone button in the text field dictates into it with the platform's speech recognizer: Apple's Speech framework on iOS, and Android's SpeechRecognizer. It shows while the field is empty, and when tapped it asks for microphone access, records, and writes the transcript into the composer's text. It renders nothing when speech-to-text isn't part of the build, so it never reaches a missing module. isDictationAvailable() tells you at runtime whether it is.
SpeechToTextButton takes an options prop, which sets how the speech is recognized:
| Option | Description | Default |
|---|---|---|
language |
The language to recognize, such as de-DE. |
The device's language |
interimResults |
Writes the words so far while the user speaks, rather than only the final result. | true |
silenceTimeoutMs |
How many milliseconds of silence end the dictation. | 2500 |
The composer's own button takes the defaults. To give it options, or to put the button elsewhere, render SpeechToTextButton yourself, and pass your component as the composer's SpeechToTextButton. null leaves the composer's out:
import {
ComposerView,
SpeechToTextButton,
} from "stream-chat-react-native/ai-components";
const options = {
language: "de-DE", // recognize German
interimResults: false, // only the final result
silenceTimeoutMs: 3500, // wait 3.5 seconds of silence
};
// Declared outside the screen, so the composer gets the same component on every render.
const GermanDictationButton = () => <SpeechToTextButton options={options} />;
<ComposerView
SpeechToTextButton={GermanDictationButton}
conversation={conversation}
/>;Dictation needs setup in the native project, which differs by platform.
iOS
Speech-to-text is opt-in on iOS. App Store Connect requires a speech recognition usage description from every app whose binary references the Speech framework, so the SDK only compiles the speech-to-text module into apps that ask for it. That keeps apps that don't use it from being rejected for a permission they never request.
To enable it, add this line to your Podfile, before use_native_modules!, and run pod install:
$StreamChatEnableDictation = trueRunning STREAM_CHAT_ENABLE_DICTATION=1 pod install works as well. Then add the usage descriptions to Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access your microphone to capture your voice.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice.</string>Choose any permission strings you want. Without the opt-in, the microphone button is hidden, and a development-only warning explains how to enable it.
Android
Add the permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO" />The module is always part of the Android build, and the first dictation asks for the permission at runtime.
Expo
Add the stream-chat-expo config plugin to your app.json or app.config.[js|ts]. It enables the iOS speech-to-text module, adds the iOS usage descriptions and adds the Android permission:
"plugins": [
// ... rest of your plugins
[
"stream-chat-expo",
{
"dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.",
"dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice."
}
]
],Both descriptions are optional, and the plugin uses default texts when you leave them out. Pass "enableDictation": false to leave speech-to-text out of the app. The plugin applies on npx expo prebuild, so run it again after changing the options. If you maintain the native projects yourself instead of generating them with prebuild, follow the iOS and Android steps above.
Dictate somewhere else
The useDictation hook, which the button is built on, dictates into a place of your own, such as a voice mode that sends what it heard. It takes the same options as the button, and a source that says who is dictating, so the composer, which only takes dictation of its own, leaves yours alone:
import {
useDictation,
useDictationState,
useDictationTranscript,
} from "stream-chat-react-native/ai-components";
const { cancel, start, stop, toggle } = useDictation(undefined, {
source: "voice-mode",
});
const { error, isRecording, state } = useDictationState();
const { transcript } = useDictationTranscript();useDictationState follows the recognizer, 'idle', 'starting', 'listening' or 'stopping', and the error of the last attempt, and useDictationTranscript the words so far. To move a voice visualization with the microphone, use useDictationAudioLevel. The Voice agent use case builds a hands-free conversation from them.
Styling and texts
The composer takes its colors from the AI components' theme, which StreamTheme provides. The theme's composer key styles the composer, its input and its buttons, such as container, inputPill, textInput, sendIcon and trailing, sheet styles the bottom sheet, and chatOptions styles the chat options and their chip, which the "Editing message" chip shares:
<StreamTheme
style={{
composer: { inputPill: { borderRadius: 12 }, textInput: { fontSize: 17 } },
}}
>
{children}
</StreamTheme>Its texts, such as the placeholder, "Editing message" or the names of the buttons for screen readers, go through the Chat SDK's translations inside Chat, so you can change or translate them with Streami18n. Outside Chat, they're in English.