Info:
This is documentation for Stream Chat React Native SDK v9, which is no longer actively maintained. For up-to-date documentation, see the latest version (v10).

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 screen 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 Channel or MessageList of the Chat SDK.

Info:

AIConversation is available from version 9.14.0 of stream-chat-react-native and stream-chat-expo, in the stream-chat-react-native/ai-components/conversation and stream-chat-expo/ai-components/conversation entry points, which need react-native-keyboard-controller. Everything else on this page is in stream-chat-react-native/ai-components and stream-chat-expo/ai-components. See Install the SDKs.

Show a conversation

Pass the messages to the conversation, oldest first, with your composer below it:

import { View } from "react-native";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import {
  type AIMessage,
  ComposerView,
} from "stream-chat-react-native/ai-components";

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 = () => (
  <View style={{ flex: 1 }}>
    <AIConversation messages={messages} />
    <ComposerView onSendMessage={send} />
  </View>
);

The conversation fills the room its parent gives it and scrolls inside itself, so give the parent a height, for example with flex: 1. Render it inside StreamTheme, like the other AI components.

The conversation shows what you pass it, which suits an app that already keeps the conversation's state. To have the SDK keep that state for you, give the conversation and the composer an AIConversationController instead, as the Quickstart does.

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 screen, 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.

The list is a LegendList, which reuses its rows as the user scrolls. A row scrolled out of sight shows another message next, so a component of your own that keeps state should start afresh for a new message.id, for example with a key.

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 StreamingMessageView. 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. -
uiDocument The UI the agent wrote for a reply, shown in place of its text. See Generative UI. -
rating How the user rated a reply, as your backend has it: 'positive', 'negative' or null. A controller shows it. -

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, its images and files are its other attachments, and its UI is its ai_ui field, as described in Reasoning and Tool Calls.

createFromStreamMessage makes a fromStreamMessage that gives back the same AIMessage for the same Stream message. A channel's state replaces only the messages that change, so mapping its messages through it on every update leaves the others as they were, and the conversation renders only the changed ones again. Make one per conversation:

import { useEffect, useMemo, useState } from "react";
import type { Channel, LocalMessage } from "stream-chat";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import { createFromStreamMessage } from "stream-chat-react-native/ai-components";

export const ChannelConversation = ({ channel }: { channel: Channel }) => {
  const [messages, setMessages] = useState<LocalMessage[]>([]);
  const [fromStreamMessage] = useState(() => createFromStreamMessage());

  useEffect(() => {
    const update = () => setMessages([...channel.state.messages]);
    update();
    const subscription = channel.on(update);
    return () => subscription.unsubscribe();
  }, [channel]);

  const conversation = useMemo(
    () => messages.map(fromStreamMessage),
    [fromStreamMessage, messages],
  );

  return <AIConversation messages={conversation} />;
};

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:

createFromStreamMessage({
  isAssistant: (message) =>
    (message.user as { id?: string } | undefined)?.id === "my-agent",
  reasoningText: (messageId, stepId) => reasoning.get(messageId, stepId),
});

fromStreamMessage(message, options) takes the same options, for a single message.

The keyboard

AIConversation handles the keyboard itself, with react-native-keyboard-controller: it keeps the user's question in place and makes room for the keyboard below the latest turn. Its entry point needs the library, with its KeyboardProvider above the conversation and no KeyboardAvoidingView around it:

yarn add react-native-keyboard-controller
import { KeyboardProvider } from "react-native-keyboard-controller";

export const App = () => (
  <KeyboardProvider>
    <StreamTheme>{/* ... */}</StreamTheme>
  </KeyboardProvider>
);

With Expo, use npx expo install react-native-keyboard-controller instead, which needs a development build.

ComposerView uses the library when it's installed, with its KeyboardProvider above the composer: it rides the keyboard up. Without the library, as in an app that shows replies in views of its own, the composer doesn't move with the keyboard, so keep it in view yourself, for example with a KeyboardAvoidingView.

Both take a keyboardOffset: how far above the bottom of the screen the composer sits, such as above a tab bar. The keyboard covers the conversation only as far as it rises past that. It defaults to the bottom safe area, so give both the same value when you change it.

Show the agent thinking

A controller does this for you. Without one, 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, attachments or UI.
const last = messages.at(-1);
const replyShowsNothing =
  !last ||
  last.role === "user" ||
  (last.text === "" &&
    !last.parts?.length &&
    !last.attachments?.length &&
    !last.uiDocument);

<AIConversation
  messages={messages}
  isThinking={isAnswering && replyShowsNothing}
/>;

The conversation also knows the agent is thinking while a message the user sent is on its way, as described next, and while a reply is being written but shows nothing. A reply joins the list once it shows something. While a reply is being written, every step it has is finished and its answer hasn't grown, "Thinking..." shows under its steps too, for models that write a round of reasoning only once it's done.

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. A controller shows it straight away by itself. Without one, pass it as the pendingMessage, and it shows as theirs straight away:

import { useState } from "react";
import { generateRandomId } from "stream-chat-react-native";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import {
  type AIPendingMessage,
  ComposerView,
  type ComposerViewProps,
} from "stream-chat-react-native/ai-components";

const [sending, setSending] = useState<AIPendingMessage | null>(null);

const send: NonNullable<ComposerViewProps["onSendMessage"]> = async ({
  attachments = [],
  text,
}) => {
  const id = generateRandomId();
  setSending({
    id,
    text,
    attachments: attachments.map((asset) => ({
      kind: asset.type?.startsWith("image") ? "image" : "file",
      name: asset.name,
      size: asset.size,
      url: asset.uri,
    })),
  });
  try {
    await channel.sendMessage({
      id,
      text,
      attachments: await upload(attachments),
    });
  } catch {
    setSending(null);
  }
};

<AIConversation messages={messages} pendingMessage={sending} />;
<ComposerView onSendMessage={send} />;

Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, showing the photos as they were picked rather than loading them again, 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

With a controller, the transport's loadOlderMessages does this. Without one, 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.

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. AITypingIndicatorView fits the footer:

import { AIConversation } from "stream-chat-react-native/ai-components/conversation";
import { AITypingIndicatorView } from "stream-chat-react-native/ai-components";

<AIConversation
  messages={messages}
  footer={activity ? <AITypingIndicatorView text={activity} /> : undefined}
  emptyContent={<Text>What can I help with?</Text>}
/>;

AITypingIndicatorView shows its text, such as "Working on your calendar", with a shimmer.

Suggestions to start with

AISuggestions shows a row of starters, scrolling sideways, and calls onSelect with the one picked. Place it in the composer's header, so it moves with the composer:

import {
  AISuggestions,
  ComposerView,
} from "stream-chat-react-native/ai-components";

<ComposerView
  onSendMessage={send}
  header={
    messages.length === 0 ? (
      <AISuggestions
        suggestions={[
          "What's on my calendar today?",
          "Plan a weekend in Lisbon",
        ]}
        onSelect={(suggestion) => send({ text: suggestion })}
      />
    ) : null
  }
/>;

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. 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:

<View style={{ flex: 1 }}>
  <AIConversation messages={messages} bottomInset={composerHeight} />
  <View
    onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)}
    pointerEvents="box-none"
    style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}
  >
    <ComposerView onSendMessage={send} />
  </View>
</View>

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. An AssistantMessage of your own also gets onWritingChange: call it with true when the answer starts typing itself out and false when it's done, and the reply's actions wait for it. A view that never calls it has its actions shown once the reply is finished.

With generative UI, an AssistantMessage also gets showsUI, whether the reply answers with UI, which shows under it in place of its text, and uiFailed, whether its UI failed to show, so its text shows instead. With both false and an empty text, a finished reply answered with nothing, such as one the agent stopped.

Reply actions

AIReplyActions shows copy and share buttons under each finished reply that has text, and regenerate and rate buttons when you handle them. A reply that answers only with UI or attachments has no copy or share buttons. Share uses the system's share sheet. Copy shows only when the app has a clipboard library, as described in Clipboard.

A controller rates replies and writes the latest one again for you. A replyActions you pass takes its place. Without a controller, pass replyActions to say how a reply is rated and regenerated:

const [ratings, setRatings] = useState<Record<string, AIMessageRating>>({});

const replyActions = useCallback(
  (message: AIMessage, { isLatestReply }: { isLatestReply: boolean }) => ({
    rating: ratings[message.id],
    onRate: (rating: AIMessageRating | null) =>
      setRatings(({ [message.id]: _, ...rest }) =>
        rating ? { ...rest, [message.id]: rating } : rest,
      ),
    onRegenerate: isLatestReply ? regenerate : undefined,
  }),
  [ratings, regenerate],
);

<AIConversation messages={messages} replyActions={replyActions} />;

Every message renders again when replyActions changes, so keep it the same between renders with useCallback. 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 taps 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. With a controller, the user's latest message gets one while the agent isn't answering, as onEdit in the UserMessage props, so a UserMessage of your own should pass it on to AIUserMessage.

Without a controller, the default UserMessage passes no onEdit, so replace it. Here editing puts the text back in the composer:

import { AIUserMessage } from "stream-chat-react-native/ai-components";
import type { AIConversationUserMessageProps } from "stream-chat-react-native/ai-components/conversation";

const UserMessage = useCallback(
  ({ attachments, message, text }: AIConversationUserMessageProps) => (
    <AIUserMessage
      attachments={attachments}
      text={text}
      onEdit={message ? () => setDraft(message.text) : undefined}
    />
  ),
  [setDraft],
);

<AIConversation messages={messages} UserMessage={UserMessage} />;

Keep the component the same between renders, defined outside your component or with useCallback, so the rows aren't built again on every render. 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.

To have the composer show the "Editing message" chip, pass it your own state store, made with createNewComposerStore(), and set isEditing: true in it. The chip's cancel button sets it back to false and empties the text. Set it back to false yourself once the edit is sent. useComposerIsEditing reads it inside the composer.

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 type's icon, name, type and size. A file at a web URL opens in the browser when tapped. An image doesn't open.

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 a local one 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. To show a kind of your own, pass an Attachment component to AIUserMessage or AIAssistantMessage.

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 AI components' theme, which StreamTheme provides. The theme's conversation key styles the list, the spinner while older messages load and the button back to the latest message, message styles the user's and the agent's messages, their attachments and the reply actions, and suggestions styles AISuggestions. The composer's "Editing message" chip takes the chip styles of chatOptions. Its icons replace any of the icons the messages and steps draw, by name, over defaultAIIcons:

<StreamTheme
  style={{
    conversation: { scrollButtonBackground: "#ffffff" },
    icons: { Copy: MyCopyIcon },
    message: { userMessageBackground: "#e8f0ff" },
  }}
>
  {children}
</StreamTheme>

Their texts, such as "Scroll to latest", "Thinking...", "Copy" or "Editing message", go through the Chat SDK's translations inside Chat, so you can change or translate them with Streami18n. Outside Chat, they're in English.

Going further