# Conversation View

> **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)](https://getstream.io/chat/docs/sdk/react-native/).

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`](#the-keyboard). Everything else on this page is in `stream-chat-react-native/ai-components` and `stream-chat-expo/ai-components`. See [Install the SDKs](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-2-install-the-sdks).
>

## Show a conversation

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

```tsx
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`](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-controller) instead, as the [Quickstart](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#step-5-show-the-conversation) 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](https://legendapp.com/open-source/list/), 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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/) the agent took for it, in order.                                                                                           | `[]`    |
| `attachments`   | What it carries besides its text, such as images and files. See [Attachments](#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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/generative-ui/).                                                              | -       |
| `rating`        | How the user rated a reply, as your backend has it: `'positive'`, `'negative'` or `null`. A [controller](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/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:

```tsx
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:

```tsx
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`](https://kirillzyusko.github.io/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:

```bash
yarn add react-native-keyboard-controller
```

```tsx
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-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.

```tsx
// 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:

```tsx
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:

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

```tsx
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:

```tsx
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:

```tsx
<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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/#optional-libraries).

A [controller](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-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:

```tsx
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-state/#a-conversation-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:

```tsx
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.

```tsx
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](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/#tool-approvals) covers both.

```tsx
<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`:

```tsx
<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

- [ExpoChatGPTSample](https://github.com/GetStream/stream-chat-react-native/tree/v9-release/examples/ExpoChatGPTSample): A complete assistant built on these components and Stream Agents
- [Reasoning and tool calls](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/reasoning-and-tool-calls/): Show an agent's steps, and ask the user before a tool runs

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/](https://getstream.io/chat/docs/sdk/react-native/v9/ai-components/conversation-view/).