Conversation View
An AI assistant lays out a conversation differently from a chat. The conversation starts at the top and grows down, and when the user asks something, their question moves to the top of the view while the reply writes itself out below it. AIConversation gives you that layout. It shows the user's messages and the agent's replies with the AI components, including their reasoning, tool calls and attachments, and it doesn't need the MessageList of the Chat SDK.
AIConversation is available from version 14.15.0 of stream-chat-react, in the stream-chat-react/ai-components entry point. See Usage.
Show a conversation
Pass the messages to the conversation, oldest first, with your composer below it:
import {
AIConversation,
type AIMessage,
} from "stream-chat-react/ai-components";
import "stream-chat-react/css/index.css";
import "stream-chat-react/css/ai-components.css";
const messages: AIMessage[] = [
{ id: "1", role: "user", text: "Plan a weekend in Lisbon" },
{
id: "2",
role: "assistant",
text: "Here's a plan for **two days** in Lisbon...",
},
];
export const Assistant = () => (
<div className="assistant">
<AIConversation messages={messages} />
<Composer />
</div>
);Give the conversation a height, for example as the growing child of a column, since it scrolls inside itself.
Each message from the user starts a turn: that message and the replies after it. The latest turn is at least as tall as the view, so a new question can always scroll to the top, and the reply grows below it without moving anything on screen. A conversation that loads, or replaces the one shown, opens at its end. Once the user scrolls away from the end, a button brings it back into view.
An AIMessage has these fields:
| Field | Description | Default |
|---|---|---|
id |
The message's stable identity. | - |
role |
Who wrote the message: 'user', 'assistant', or a role of your own, which shows as the agent's. |
- |
text |
What the message says. A reply's text is Markdown. | - |
isGenerating |
Whether the agent is still writing it. While it is, the reply types itself out, as with StreamingMessage. |
false |
parts |
The steps the agent took for it, in order. | [] |
attachments |
What it carries besides its text, such as images and files. See Attachments. | [] |
reasoningText |
(stepId) => string: the whole of a reasoning step's thinking, when your app has it. Otherwise the step's excerpt shows. |
- |
reasoningText is called on every render, so look the text up rather than work it out.
Messages from Stream Chat
fromStreamMessage turns a Stream Chat message into an AIMessage. A reply is a message with ai_generated: true, it's being written while generating is true, its steps are its ai_reasoning and ai_tool_call attachments, and its images and files are its other attachments, as described in Reasoning and Tool Calls:
import { useEffect, useState } from "react";
import type { Channel, LocalMessage } from "stream-chat";
import {
AIConversation,
fromStreamMessage,
} from "stream-chat-react/ai-components";
export const ChannelConversation = ({ channel }: { channel: Channel }) => {
const [messages, setMessages] = useState<LocalMessage[]>([]);
useEffect(() => {
const update = () => setMessages([...channel.state.messages]);
update();
const subscription = channel.on(update);
return () => subscription.unsubscribe();
}, [channel]);
return (
<AIConversation
messages={messages.map((message) => fromStreamMessage(message))}
/>
);
};A channel's state lists its messages oldest first, which is the order the conversation expects. Pass isAssistant to tell the agent's messages apart some other way, such as by the user who sent them, and reasoningText when your app has the whole of the reasoning:
fromStreamMessage(message, {
isAssistant: (message) => message.user?.id === "juno",
reasoningText: (messageId, stepId) => reasoning.get(messageId, stepId),
});Show the agent thinking
Pass isThinking only while the agent is answering and its reply shows nothing yet. The latest turn then ends with AIThinking, "Thinking... 4s", counting from when the question was asked. Once the reply shows something, pass false: its first reasoning step takes over the same timer, so the count goes on without resetting, and the step folds into "Thought for 8s" when the model is done.
// The latest reply shows nothing yet: there is none, or it has no text, steps or attachments.
const last = messages.at(-1);
const replyShowsNothing =
!last ||
last.role === "user" ||
(last.text === "" && !last.parts?.length && !last.attachments?.length);
<AIConversation
messages={messages}
isThinking={isAnswering && replyShowsNothing}
/>;The conversation also knows the agent is thinking while a reply is being written but shows nothing, and while a message the user sent is on its way, as described next. A reply joins the list once it shows something.
AIThinking works on its own too. Outside the conversation it counts from when it appears, or from since.
Show a message while it's sent
A message the user sends can take a moment to reach the conversation, for example while its photos upload. Pass it as the pendingMessage, and it shows as theirs straight away:
const [sending, setSending] = useState<AIPendingMessage | null>(null);
const send = async (text: string, files: File[]) => {
const id = crypto.randomUUID();
setSending({
id,
text,
attachments: files.map((file) => ({
kind: file.type.startsWith("image/") ? "image" : "file",
name: file.name,
size: file.size,
url: URL.createObjectURL(file),
})),
});
try {
await channel.sendMessage({ id, text, attachments: await upload(files) });
} catch {
setSending(null);
}
};
<AIConversation messages={messages} pendingMessage={sending} />;Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, and the view doesn't move. Pass the id the message will have, as above, so the copy is found by its id rather than by where it lands. If sending fails, pass null again, or the message and "Thinking..." stay on screen.
Load older messages
Pass loadOlderMessages to load the messages before the first one. The conversation calls it when the user scrolls to its start, and shows a spinner meanwhile. The messages on screen stay in place as the older ones arrive. Pass undefined once there are none left:
<AIConversation
messages={messages}
loadOlderMessages={hasOlderMessages ? loadOlderMessages : undefined}
/>With Stream Chat, loadOlderMessages can call channel.query({ messages: { limit: 30, id_lt: oldestMessageId } }), and there are none left once a page comes back with fewer than 30.
Footer and empty state
The footer shows under the latest turn's messages, for example what the agent is doing, or an error with a way to retry. The emptyContent shows, centered, while there are no messages. AITypingIndicator and AISuggestions fit them:
import {
AISuggestions,
AITypingIndicator,
} from "stream-chat-react/ai-components";
<AIConversation
messages={messages}
footer={activity ? <AITypingIndicator text={activity} /> : undefined}
emptyContent={
<AISuggestions
suggestions={["What's on my calendar today?", "Plan a weekend in Lisbon"]}
onSelect={(suggestion) => send(suggestion, [])}
/>
}
/>;AITypingIndicator shows its text, such as "Working on your calendar", with three dots. AISuggestions shows each suggestion as a button and calls onSelect with the one picked.
A composer over the conversation
To float the composer over the bottom of the conversation, place it there yourself and pass its height as bottomInset, in pixels. The latest turn then leaves that much room under it, and a new question pins to the top of what is visible above the composer:
<div className="assistant" style={{ position: "relative" }}>
<AIConversation messages={messages} bottomInset={composerHeight} />
<div style={{ position: "absolute", insetInline: 0, bottom: 0 }}>
<Composer />
</div>
</div>Customize the messages
The conversation draws each message with a component you can replace. Each has a default, so pass only the ones you want to change:
| Prop | Shows | Default |
|---|---|---|
UserMessage |
A message the user sent, or one on its way (message is undefined). |
AIUserMessage |
AssistantMessage |
A reply: its steps, its answer and its attachments. | AIAssistantMessage |
ReplyActions |
What the user can do with a finished reply. null shows nothing. |
AIReplyActions |
Each gets your own message as message, so read what else it holds there.
Reply actions
AIReplyActions shows copy and share buttons under each finished reply, and regenerate and rate buttons when you handle them. Share uses the browser's share sheet where there is one, and copies the reply otherwise. Pass replyActions to say how a reply is rated and regenerated:
const [ratings, setRatings] = useState<Record<string, AIMessageRating>>({});
<AIConversation
messages={messages}
replyActions={(message, { isLatestReply }) => ({
rating: ratings[message.id],
onRate: (rating) =>
setRatings(({ [message.id]: _, ...rest }) =>
rating ? { ...rest, [message.id]: rating } : rest,
),
onRegenerate: isLatestReply ? regenerate : undefined,
})}
/>;The actions show once the reply's answer has finished writing itself out. isLatestReply tells the latest reply apart, for example to offer regenerating only that one. Regenerate shows only when onRegenerate is set, and the thumbs only when onRate is set. onRate receives 'positive' or 'negative', or null when the user clicks their rating again to take it back.
Editing a message
AIUserMessage shows an edit button beside the user's message when it gets an onEdit. The default UserMessage passes none, so replace it. Here editing puts the text back in the composer:
<AIConversation
messages={messages}
UserMessage={({ attachments, message, text }) => (
<AIUserMessage
attachments={attachments}
text={text}
onEdit={message ? () => setDraft(message.text) : undefined}
/>
)}
/>A message that is still on its way has no message, so it offers no edit. What sending the edited text does, such as updating the message and asking for a new reply, is up to your app.
Attachments
An AIMessageAttachmentData is something a message carries besides its text. AIMessageAttachment shows an image as a thumbnail, and any other kind as a file card with its name, type and size. Clicking it opens it in a new tab.
const attachments: AIMessageAttachmentData[] = [
{ kind: "image", url: "https://cdn.example.com/lisbon.jpg" },
{ kind: "file", url: reportUrl, name: "Report.pdf", size: 2_400_000 },
];url can be a web URL, or an object or data URL of something your app holds, such as a photo the user just picked. Without a mimeType, the type comes from the name or the URL. The kinds of attachments are an open set: a kind the components don't know shows as a file, and extraData holds what a kind of your own needs.
Tool calls
Pass an AIToolApprover as the approver, and the conversation asks the user about tool calls that wait for them: an approval, or a sign-in at a provider when the approver has an AIToolAuthorizer. Reasoning and Tool Calls covers both.
<AIConversation messages={messages} approver={approver} />Styling and texts
The components take their colors from the SDK's theme variables, in light and dark mode, and their class names start with str-chat__ai-. Their texts, such as "Scroll to latest", "Thinking..." or "Copy", go through the SDK's translations, so you can change or translate them with Streami18n.