Composer
AIMessageComposer is where the user writes to your agent. It's a form with a text input that grows with its lines, file attachments with previews, dictation through the browser's speech recognition, a menu of chat options such as "Think harder", and a model picker. You put it together from its parts, and it hands you what the user wrote when they send it:
import { AIMessageComposer } from "stream-chat-react/ai-components";
export const Composer = () => (
<AIMessageComposer onSubmit={handleSubmit}>
<AIMessageComposer.AttachmentPreview />
<AIMessageComposer.TextInput />
<div className="composer-actions">
<AIMessageComposer.FileInput name="attachments" />
<AIMessageComposer.SpeechToTextButton />
<AIMessageComposer.SubmitButton />
</div>
</AIMessageComposer>
);.composer-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* The send button, last in the row, goes to the far end. */
.composer-actions > :last-child {
margin-inline-start: auto;
}AIMessageComposer is available from version 14.13.0 of stream-chat-react, in the stream-chat-react/ai-components entry point, and its chat options from 14.15.0. See the Quickstart to install it and load its stylesheet.
The parts are static properties of AIMessageComposer, not separate exports, and each reads the composer's state from it, so render them inside it. Lay them out with your own elements around them as you like. The form stacks what's inside it, with the text field growing to fill it, so give the buttons a row of their own, as above. The form also grows to fill a flex container it's placed in, so put it in an element of its own when it sits under a conversation, and render it inside an element with the str-chat class, the SDK's theme scope, for the SDK's fonts and colors.
Send what the user writes
AIMessageComposer renders a form, and takes every prop of one. It keeps the text and the picked files in a store of its own, and these props on top:
| Prop | Description | Default |
|---|---|---|
onSubmit |
Called when the user sends: with the submit button, or Enter in the text input. | - |
onChange |
Called on every change in the form, before the composer reads it. | - |
disabled |
Disables every part of the composer. | - |
nameMapping |
The names of the inputs the composer reads the text and the files from. | { message: "message", attachments: "attachments" } |
resetAttachmentsOnSelect |
Clears the file input after each selection, so the same file can be picked again. The picked files stay in the composer's store. | true |
The text input is named message already. FileInput has no name of its own, so give it name="attachments" for the composer to pick up its files, or another name that nameMapping maps.
Since the file input is cleared after each selection, the form's data on submit holds the text, but not the files. Keep the files as the user picks them, in onChange, and show them in AttachmentPreview:
import { type FormEvent, useState } from "react";
import { AIMessageComposer } from "stream-chat-react/ai-components";
type Picked = { file: File; id: string };
export const Composer = ({
onSend,
}: {
onSend: (text: string, files: File[]) => void;
}) => {
const [picked, setPicked] = useState<Picked[]>([]);
const handleChange = (event: FormEvent<HTMLFormElement>) => {
const input = event.target;
if (input instanceof HTMLInputElement && input.name === "attachments") {
const files = Array.from(input.files ?? []);
setPicked((current) => [
...current,
...files.map((file) => ({ file, id: crypto.randomUUID() })),
]);
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = new FormData(event.currentTarget).get("message");
onSend(
typeof text === "string" ? text : "",
picked.map(({ file }) => file),
);
event.currentTarget.reset();
setPicked([]);
};
return (
<AIMessageComposer onChange={handleChange} onSubmit={handleSubmit}>
<AIMessageComposer.AttachmentPreview>
{picked.map(({ file, id }) => (
<AIMessageComposer.AttachmentPreview.Item
file={file}
key={id}
onDelete={() =>
setPicked((current) => current.filter((item) => item.id !== id))
}
/>
))}
</AIMessageComposer.AttachmentPreview>
<AIMessageComposer.TextInput />
<div className="composer-actions">
<AIMessageComposer.FileInput name="attachments" />
<AIMessageComposer.SubmitButton />
</div>
</AIMessageComposer>
);
};Resetting the form clears the composer's text and files, and keeps it disabled if disabled says so.
To send through a Stream Chat channel, with its uploads, render the composer as the channel's composer, as SDK integration shows, and add a stop button.
Control it from your code
Inside the composer, these hooks read and change its state:
| Hook | Returns |
|---|---|
useText() |
text, and setText(text) to replace it. |
useAttachments() |
attachments, each a { id, file, meta? }, with removeAttachment(idOrFile) and updateAttachments(ids, update). |
useIsDisabled() |
disabled, and setDisabled(disabled). |
useAIMessageComposerContext() |
The composer's store, an AIMessageComposerStore in a StateStore. |
They're exported from stream-chat-react/ai-components. Outside a composer, they read a store no composer uses. For example, a button that fills in a prompt:
import { Button } from "stream-chat-react";
import { AIMessageComposer, useText } from "stream-chat-react/ai-components";
const PromptButton = ({ prompt }: { prompt: string }) => {
const { setText } = useText();
return (
<Button
appearance="outline"
onClick={() => setText(prompt)}
size="sm"
type="button"
variant="secondary"
>
{prompt}
</Button>
);
};
<AIMessageComposer onSubmit={handleSubmit}>
<AIMessageComposer.TextInput />
<div className="composer-actions">
<PromptButton prompt="Summarize this thread" />
<AIMessageComposer.SubmitButton />
</div>
</AIMessageComposer>;Chat options
AIMessageComposer.ChatOptions adds a menu of ways to ask, such as Think harder, and shows the one picked as a chip the user can remove. With a name, the picked option's id goes with the form's data:
import {
AIMessageComposer,
type AIChatOption,
} from "stream-chat-react/ai-components";
const thinkHarder: AIChatOption = {
id: "think_harder",
title: "Think harder",
description: "Takes longer, for harder questions",
shortTitle: "Think",
};
export const Composer = () => (
<AIMessageComposer onSubmit={handleSubmit}>
<AIMessageComposer.TextInput />
<div className="composer-actions">
<AIMessageComposer.FileInput name="attachments" />
<AIMessageComposer.ChatOptions name="option" options={[thinkHarder]} />
<AIMessageComposer.SubmitButton />
</div>
</AIMessageComposer>
);On submit, new FormData(event.currentTarget).get("option") is "think_harder", or "" when nothing is picked.
| Prop | Description | Default |
|---|---|---|
options |
The options: an id and a title, and an optional description, icon, shortTitle for the chip and customData. |
- |
name |
Submits the picked option's id with the form under this name. |
- |
onSelectedChange |
Called with the option the user picks, or null when they remove it. |
- |
selectedId |
The picked option's id, when you control it. |
- |
defaultSelectedId |
The option picked at first, when you don't. | null |
icon |
The menu button's icon. | A plus |
disabled |
Disables the menu and the chip. | The composer's |
Picking the option that's already picked removes it. ChatOptionsList and SelectedChatOptionChip, the menu's items and the chip, are exported too, to build a menu of your own.
Stop the reply
AIMessageComposer has no stop button. Add one of your own while the agent answers. 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 {
AIStates,
Button,
useAIState,
useChannelStateContext,
} from "stream-chat-react";
const activeStates = [
AIStates.Thinking,
AIStates.ExternalSources,
AIStates.Generating,
];
export const StopButton = () => {
const { channel } = useChannelStateContext();
const { aiState } = useAIState(channel);
if (!activeStates.includes(aiState)) return null;
return (
<Button
appearance="outline"
onClick={() => channel.stopAIResponse()}
size="sm"
type="button"
variant="secondary"
>
Stop
</Button>
);
};Render it in the composer's row of buttons, in place of the send button while it shows, and the composer inside Channel. type="button" keeps the click from submitting the form. The button doesn't hide itself on click: it goes once ai_indicator.stop, or the backend's ai_indicator.clear, reaches the channel and moves aiState out of the active states.
The default MessageComposer of stream-chat-react has a stop button of its own, StopAIGenerationButton. It shows only while aiState is AI_STATE_THINKING or AI_STATE_GENERATING, not during AI_STATE_EXTERNAL_SOURCES, so the send button comes back while a tool runs. In a channel with slow mode, the cooldown timer takes its place until the cooldown ends, and with audioRecordingEnabled on MessageComposer, the record button takes its place whenever the input is empty, which it usually is while a reply streams. Replacing the composer's UI with AIMessageComposer removes it. SDK integration shows how to restyle or remove it.
Stop the reply covers what the backend does with the event.
Dictation
AIMessageComposer.SpeechToTextButton turns speech into text with the browser's Web Speech API, and writes the transcript into the composer's text input. Its options go to the speech recognizer:
<AIMessageComposer onSubmit={handleSubmit}>
<AIMessageComposer.TextInput />
<div className="composer-actions">
<AIMessageComposer.SpeechToTextButton
options={{ lang: "en-US", continuous: false, interimResults: true }}
/>
<AIMessageComposer.SubmitButton />
</div>
</AIMessageComposer>| Option | Description | Default |
|---|---|---|
lang |
The language to recognize. | "en-US" |
interimResults |
Writes the words so far while the user speaks. | true |
continuous |
Keeps listening after the user stops speaking. | false |
maxAlternatives |
The most alternatives the browser returns for each result. The first one is used. | 1 |
onTranscript |
Called with the transcript, besides writing it into the input. | - |
onError |
Called with an English description of what went wrong, in place of logging it. | Logs to console.error |
The button takes the props of a button too. When clicked, the browser asks for the microphone, and the button shows as pressed (aria-pressed) while it listens. Clicking it again stops it.
The button renders nothing in browsers without the Web Speech API, such as Firefox. Support is only known in the browser, so with server rendering the button appears after hydration.
To dictate into an input of your own, use the useSpeechToText hook from stream-chat-react/ai-components, which the button is built on. It takes the same options and returns:
isSupported: whether the browser has speech recognition. Alwaysfalseduring server rendering.isListening: whether it's listening.startListening(): starts listening. It throws whenisSupportedisfalse, and callsonErrorwhen the browser refuses to start.stopListening(): stops listening.
onTranscript and onError may be inline functions: the hook always calls the latest ones, and makes its recognizer again only when lang, interimResults, continuous or maxAlternatives changes. For example, a dictation button for the default MessageComposer:
import { useMessageComposerController } from "stream-chat-react";
import { useSpeechToText } from "stream-chat-react/ai-components";
export const DictationButton = () => {
const messageComposer = useMessageComposerController();
const { isListening, isSupported, startListening, stopListening } =
useSpeechToText({
onTranscript: (text) => messageComposer.textComposer.setText(text),
});
if (!isSupported) return null;
return (
<button
aria-pressed={isListening}
onClick={isListening ? stopListening : startListening}
type="button"
>
{isListening ? "Stop dictation" : "Dictate"}
</button>
);
};With server rendering, render the button only once the component has mounted, as SpeechToTextButton does: isSupported is false on the server and can be true in the browser.
To let the user talk to your agent instead, rather than dictate, see the Voice agent use case.
Customize the composer
Each part takes the props of the element it renders, so you can name it, style it or handle its events:
| Part | Renders |
|---|---|
AIMessageComposer.TextInput |
A textarea named message that grows with its lines up to 200 px, then scrolls. Enter sends, and Shift+Enter starts a new line. |
AIMessageComposer.FileInput |
A round button that picks one or more files. The native file input stays focusable for keyboard users, and labelProps go to the round button. |
AIMessageComposer.SpeechToTextButton |
The dictation button. |
AIMessageComposer.ChatOptions |
The chat options menu and chip. |
AIMessageComposer.ModelSelect |
A select of models. Pass your own <option> elements as options. |
AIMessageComposer.SubmitButton |
The send button. active highlights it, for example while files are picked. |
AIMessageComposer.AttachmentPreview |
The picked files. Without children, it shows the composer's own files, each with a delete button, and nothing while there are none. With children, it shows them instead. |
AIMessageComposer.AttachmentPreview.Item |
One file: a thumbnail for an image, a card with the name and size for any other file, and a delete button. |
ModelSelect offers two OpenAI models by default, with values such as openai|gpt-4o-mini. Give it your own list and a name, so the pick goes with the form's data, and check on your backend that it's a model you offer:
<AIMessageComposer.ModelSelect
name="model"
options={
<>
<option value="fast">Fast</option>
<option value="smart">Smart</option>
</>
}
/>An AttachmentPreview.Item takes the file, an onDelete, and optionally a title in place of the file's name, an imagePreviewSource for an image, and an upload state. With state="failed", it shows a button that calls onRetry. The children of AttachmentPreview can also be a function of what useAttachments() returns.
Styling and texts
The composer follows the SDK's theme in light and dark mode, and its colors come from the --str-chat__ai-* variables described in Theming: border, surface-hover and surface-pressed for the composer, and button-bg, scrollbar-track and scrollbar-thumb for the previews. Its class names start with str-chat__ai-message-composer, str-chat__ai-attachment-preview and str-chat__ai-chat-options.
It shows the SDK's icons, which you replace through WithComponents like any other icon. Its texts go through the SDK's translations, which also name the buttons for screen readers. Their keys are the English texts, such as Ask a question..., aria/Start voice input and aria/Chat options, and the SDK's bundled languages translate them. To change them, register your own translations, as described in Localization.