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

>
> **Info:** `AIConversation` is available from version 14.15.0 of `stream-chat-react`, in the `stream-chat-react/ai-components` entry point. See [Usage](https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/#usage).
>

## Show a conversation

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

```tsx
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](https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/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. | -       |

`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](https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/reasoning-and-tool-calls/):

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

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

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

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

```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. `AITypingIndicator` and `AISuggestions` fit them:

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

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

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

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

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

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

## Going further

- [React assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/react_assistant): A complete personal assistant built on these components and Stream Agents
- [Reasoning and tool calls](https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/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/guides/ai-integrations/conversation-view/](https://getstream.io/chat/docs/sdk/react/guides/ai-integrations/conversation-view/).