# 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. `AIConversationView` 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 message list of `stream_chat_flutter`.

>
> **Info:** `AIConversationView` is available from version 0.2.0 of `stream_chat_flutter_ai`. See [Install the SDKs](https://getstream.io/chat/docs/sdk/flutter/ai-components/#step-2-install-the-sdks).
>

## Show a conversation

Mix `AIMessage` into your own message type, or wrap one with it. A message needs an `id`, a `role` and its `text`. The other members have defaults:

```dart
class ChatMessage with AIMessage {
  ChatMessage({
    required this.id,
    required this.role,
    required this.text,
    this.isGenerating = false,
  });

  @override
  final String id;

  @override
  final AIMessageRole role;

  @override
  final String text;

  @override
  final bool isGenerating;
}
```

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

```dart
Column(
  children: [
    Expanded(
      child: AIConversationView(messages: messages),
    ),
    ChatComposer(
      controller: controller,
      onSendPressed: send,
      onStopPressed: stop,
    ),
  ],
)
```

Place the composer below the list, or [float it over the list](#float-the-composer-over-the-list) and tell the list how much of it is covered. Either way, the list knows how much of it is visible. The list needs a bounded height, such as the `Expanded` above. Its `padding` sets the space around the messages, 16 on the sides and at the bottom by default.

Each message from the user starts a turn: that message and the replies after it. Messages before the user's first one, such as a greeting, make a turn of their own. The latest turn is at least as tall as the list, 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.

`AIMessage` has these members:

| Member                | Description                                                                                                                                       | Default    |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| `id`                  | The message's stable identity.                                                                                                                    | -          |
| `role`                | Who wrote the message: `AIMessageRole.user`, `AIMessageRole.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/flutter/ai-components/reasoning-and-tool-calls/) the agent took for it, in order.                                      | `const []` |
| `attachments`         | What it carries besides its text, such as images and files. See [Attachments](#attachments).                                                      | `const []` |
| `uiDocument`          | The UI the agent wrote for a reply, which shows in place of its text. See [Generative UI](#generative-ui).                                        | `null`     |
| `rating`              | How the user rated a reply, as your backend keeps it. See [Conversation State](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#actions). | `null`     |
| `reasoningText(step)` | The whole of a reasoning step's thinking, when your app has it, such as from live updates. Otherwise the step's excerpt shows.                    | `null`     |

`reasoningText` is called each time the conversation rebuilds, so look the text up rather than work it out.

### Messages from Stream Chat

`stream_chat_flutter_ai` doesn't depend on Stream Chat, so it has no ready-made `AIMessage` for a Stream Chat `Message`. Wrap one in your app, with both packages imported as described in [Stream Chat integration](https://getstream.io/chat/docs/sdk/flutter/ai-components/stream-chat-integration/). The role comes from the `ai_generated` field, as described in [Mark the agent's replies](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#mark-the-agents-replies), and the reply's steps from its attachments, as described in [Reasoning and Tool Calls](https://getstream.io/chat/docs/sdk/flutter/ai-components/reasoning-and-tool-calls/#show-the-steps-of-a-reply):

```dart
class StreamAIMessage with AIMessage {
  StreamAIMessage(this.message)
    // The offline database stores a step's `id` as `Attachment.id`, and the
    // network keeps it in extraData, which is spread last so it wins.
    : parts = AIMessagePart.parseAll([
        for (final attachment in message.attachments)
          if (attachment.rawType case final type?)
            (type: type, payload: {'id': attachment.id, ...attachment.extraData}),
      ]),
      attachments = [
        for (final attachment in message.attachments)
          ?toAIMessageAttachment(attachment),
      ];

  final Message message;

  @override
  String get id => message.id;

  @override
  AIMessageRole get role =>
      message.extraData['ai_generated'] == true ? AIMessageRole.assistant : AIMessageRole.user;

  @override
  String get text => message.text ?? '';

  @override
  bool get isGenerating => message.extraData['generating'] == true;

  @override
  final List<AIMessagePart> parts;

  @override
  final List<AIMessageAttachment> attachments;
}

AIMessageAttachment? toAIMessageAttachment(Attachment attachment) => switch (attachment) {
  Attachment(type: AttachmentType.image, imageUrl: final url?) =>
    AIMessageAttachment.image(Uri.parse(url), name: attachment.title),
  Attachment(type: AttachmentType.file, assetUrl: final url?) => AIMessageAttachment.file(
    Uri.parse(url),
    name: attachment.title,
    size: attachment.fileSize,
    mimeType: attachment.mimeType,
  ),
  _ => null,
};
```

The members are read on every rebuild while a reply streams, so the wrapper decodes the steps and attachments once, when it's created, rather than in a getter. The conversation hands your message back in the props of the [component builders](#customize-the-messages), so you can match `props.message` against `StreamAIMessage` to read the rest of the Stream message.

A channel's state lists its messages oldest first, which is the order the conversation expects. Keep only the regular ones, leaving out system, deleted and ephemeral messages:

```dart
StreamBuilder<List<Message>>(
  stream: channel.state!.messagesStream,
  initialData: channel.state!.messages,
  builder: (context, snapshot) => AIConversationView(
    messages: [
      for (final message in snapshot.data!)
        if (message.type == MessageType.regular) StreamAIMessage(message),
    ],
  ),
)
```

## Let a conversation drive it

The view shows what you pass it. Something still has to tell it when the agent is thinking, which message is on its way and whether older messages can load, and act on what the user does: send, stop, regenerate, rate or edit. `AIConversation` does all of that, from a transport you write for your agent, and both views follow it:

```dart
Column(
  children: [
    Expanded(child: AIConversationView.conversation(conversation: conversation)),
    ChatComposer.conversation(conversation: conversation),
  ],
)
```

The [Conversation State](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/) guide covers it, with a transport over a Stream Chat channel. The rest of this page describes the view's own inputs, for when you drive the view yourself, and how to customize it either way.

## Float the composer over the list

To let the conversation scroll under the composer, put it in the `bottomNavigationBar` of a `Scaffold` with `extendBody: true`, and pass its height as the bottom of the list's `padding`:

```dart
Scaffold(
  extendBody: true,
  // Read below the Scaffold, where the bottom padding is the composer's height.
  body: Builder(
    builder: (context) => AIConversationView(
      messages: messages,
      padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + MediaQuery.paddingOf(context).bottom),
    ),
  ),
  bottomNavigationBar: Padding(
    // The bar stays at the bottom of the screen, so lift it above the keyboard.
    padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
    child: SafeArea(
      top: false,
      child: ChatComposer(
        controller: controller,
        onSendPressed: send,
        onStopPressed: stop,
      ),
    ),
  ),
)
```

The messages scroll under the padding but stop clear of it. The latest turn is at least as tall as the part of the list the padding leaves visible, so a new question still scrolls to the top of what the user sees. The button that brings the end back into view sits just above the padding. When the padding changes while the end is in view, such as when the composer grows with a longer message, the list keeps the end in view. The `padding` replaces the list's own spacing, so keep its 16 on the sides and at the bottom, as above.

## Show the agent thinking

Pass `isThinking: true` only while the agent is answering and its reply shows nothing yet. The latest turn then ends with an `AIThinkingView`, "Thinking... 4s", counting from when the question was asked when you pass it as the `pendingMessage`, and otherwise from when `isThinking` turns `true`. 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. Reasoning that arrives already finished shows open, then folds before the answer types out. Passing `true` after that keeps "Thinking..." under the reply while it streams.

With Stream Chat, the agent is thinking from the moment your backend sends `AI_STATE_THINKING` until the reply starts, as described in [Say what the agent is doing](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#say-what-the-agent-is-doing). This helper says whether the latest reply shows nothing yet:

```dart
// Whether the latest reply shows nothing yet: there is none, or it has no
// text, steps or attachments.
bool replyShowsNothing(List<AIMessage> messages) => switch (messages.lastOrNull) {
  null => true,
  final last =>
    last.role == AIMessageRole.user ||
        (last.text.isEmpty && last.parts.isEmpty && last.attachments.isEmpty),
};
```

Combine the two:

```dart
AIConversationView(
  messages: messages,
  isThinking: switch (aiState) {
    AITypingState.thinking || AITypingState.checkingSources => replyShowsNothing(messages),
    _ => false,
  },
)
```

Here `aiState` is what the agent says it's doing, the `AITypingState` of the channel's latest `ai_indicator.update`, as the transport in [Conversation State](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#a-transport-over-a-stream-chat-channel) follows it. The conversation also knows the agent is thinking while the latest 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.

`AIThinkingView` works on its own too. Outside the conversation it counts from its `since` time, or from when it appears.

## Show a message while it's sent

A message the user sends can take a moment to reach the conversation, for example while its channel is created. Pass it as the `pendingMessage`, and it shows as theirs straight away. `ChatComposer` hands `send` the photos the user picked as `XFile`s:

```dart
AIPendingMessage? sending;

Future<void> send(String text, ChatOption? option, List<XFile> photos) async {
  final picked = [
    for (final photo in photos) (name: photo.name, bytes: await photo.readAsBytes()),
  ];
  final message = Message(
    text: text,
    attachments: [
      for (final photo in picked)
        Attachment(
          type: AttachmentType.image,
          file: AttachmentFile(bytes: photo.bytes, name: photo.name, size: photo.bytes.length),
        ),
    ],
  );
  setState(() {
    sending = AIPendingMessage(
      id: message.id,
      text: text,
      attachments: [
        for (final photo in picked)
          AIMessageAttachment.memoryImage(
            ResizeImage(MemoryImage(photo.bytes), width: 360),
            name: photo.name,
          ),
      ],
    );
  });
  try {
    await channel.sendMessage(message);
  } on Object {
    if (mounted) setState(() => sending = null);
  }
}
```

```dart
Column(
  children: [
    Expanded(
      child: AIConversationView(messages: messages, pendingMessage: sending),
    ),
    ChatComposer(onSendPressed: send),
  ],
)
```

Keep passing it until its reply starts, then set it back to `null`, for example once the latest message is the agent's. The first message from the user that arrives after it takes its place, keeping the pending message's attachments, and the list doesn't move. Pass the id the message will have, as above, so its copy is found by its id rather than by where it lands: a copy that arrives before the conversation first shows the pending message is only found by its id. If sending fails, pass `null` again, or the message and "Thinking..." stay on screen.

`channel.sendMessage` adds the message to the channel's state before it uploads the photos, so with Stream Chat its copy arrives almost at once. The photos stay on screen from the pending message rather than loading again from their uploads. `AIMessageAttachment.memoryImage` keeps the image in memory while the conversation shows it, so pass a small one, the size it shows at, as `ResizeImage` does above.

## Load older messages

Pass `loadOlderMessages` to load the messages before the first one. While you pass it, the list shows a spinner at its start, and calls it once the spinner comes into view: when the user scrolls to the start, or as soon as the conversation opens if the start is already on screen. The messages on screen stay in place as the older ones arrive. Pass `null` once there are none left:

```dart
AIConversationView(
  messages: messages,
  loadOlderMessages: hasOlderMessages ? loadOlderMessages : null,
)
```

With Stream Chat, query the messages before the oldest one. They join the channel's state, and so the conversation. Start `hasOlderMessages` at `true`, and set it to `false` once a query returns fewer messages than its limit:

```dart
Future<void> loadOlderMessages() async {
  const limit = 30;
  final oldest = channel.state!.messages.firstOrNull;
  if (oldest == null) return;
  final state = await channel.query(
    messagesPagination: PaginationParams(lessThan: oldest.id, limit: limit),
  );
  if (!mounted) return;
  if ((state.messages?.length ?? 0) < limit) {
    setState(() => hasOlderMessages = false);
  }
}
```

An error thrown by `loadOlderMessages` is reported to `FlutterError.onError`. The list doesn't retry until the first message changes, or the user scrolls away from the start and back.

## 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, with the `footer` under it:

```dart
AIConversationView(
  messages: messages,
  isThinking: isWaitingForReply,
  footer: error == null ? null : RetryButton(error: error, onPressed: retry),
  emptyContent: const Text('How can I help?'),
)
```

### Suggestions

`AISuggestionsView` offers conversation starters, a row of questions the user can tap. Show it as the empty content, and send the text the user taps:

```dart
AIConversationView(
  messages: messages,
  emptyContent: AISuggestionsView(
    suggestions: const ['Plan a trip', 'Summarize my week'],
    onSuggestionSelected: (text) => send(text, null, const []),
  ),
)
```

The row scrolls sideways, and each chip shows its text on up to two lines, all at the same height. It sends nothing itself, so you can also put the text in the composer instead, as described in [Control it from your code](https://getstream.io/chat/docs/sdk/flutter/ai-components/composer/#control-it-from-your-code). You can also pass the `itemMaxWidth` of a chip (160 by default) and the row's `padding`.

Its colors and text style come from `SuggestionsThemeData`, on `AITheme.suggestionsTheme` or in a `SuggestionsTheme` scope, as described in [Theming](https://getstream.io/chat/docs/sdk/flutter/ai-components/#theming): `backgroundColor`, `borderColor` and `textStyle`. The chips default to the same tokens as the composer's input, since they're normally docked right above it, so they're outlined rather than filled. Set `backgroundColor` to fill them.

## Customize the messages

The conversation builds its messages through the builders registered with `streamAIComponentBuilders` in the nearest `StreamComponentFactory`, the same factory that replaces the package's other components, as described in [Replacing components](https://getstream.io/chat/docs/sdk/flutter/ai-components/#replacing-components). Every builder is nullable, so set only the ones you want to change:

| Builder             | Shows                                                             | Props                      | Default                                                   |
| ------------------- | ----------------------------------------------------------------- | -------------------------- | --------------------------------------------------------- |
| `userMessage`       | A message the user sent, or one on its way (`message` is `null`). | `AIUserMessageProps`       | `AIUserMessageView`                                       |
| `assistantMessage`  | A reply: its steps, its answer and its attachments.               | `AIAssistantMessageProps`  | `AIAssistantMessageView`                                  |
| `replyActions`      | What the user can do with a finished reply, under it.             | `AIReplyActionsProps`      | Nothing, or `AIReplyActionsView` with an `AIConversation` |
| `messageAttachment` | One attachment of a message, in either kind of message.           | `AIMessageAttachmentProps` | `AIMessageAttachmentView`                                 |
| `toolAuthorization` | The sign-in a tool call waits for.                                | `AIToolAuthorizationProps` | `AIToolAuthorizationCard`                                 |

- `AIUserMessageProps` has the `message`, its `text`, `attachments`, `onAttachmentTap` and `onEdit`.
- `AIAssistantMessageProps` has the `message`, its `text`, `isGenerating`, `parts` and `attachments`, the `approver` and `onAttachmentTap`.
- `AIReplyActionsProps` has the reply's `message`, its `text`, `isLatestReply` and `actions`.
- `AIMessageAttachmentProps` has the `attachment`, `isFromUser` and `onTap`.
- `AIToolAuthorizationProps` is described in [Tool sign-ins](https://getstream.io/chat/docs/sdk/flutter/ai-components/reasoning-and-tool-calls/#tool-sign-ins).

`message` is one of your own messages, so match it against your type to read what else it holds. Here each reply shows the name of the user who sent it above the default view:

```dart
// In your State: created once, not in build.
late final builders = streamAIComponentBuilders(
  assistantMessage: (context, props) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    spacing: 4,
    children: [
      if (props.message case StreamAIMessage(:final message))
        Text(message.user?.name ?? '', style: context.streamTextTheme.captionEmphasis),
      AIAssistantMessageView(
        text: props.text,
        isGenerating: props.isGenerating,
        parts: props.parts,
        attachments: props.attachments,
        approver: props.approver,
        reasoningText: props.message.reasoningText,
        onAttachmentTap: props.onAttachmentTap,
      ),
    ],
  ),
);

late final componentBuilders = StreamComponentBuilders(extensions: builders);

@override
Widget build(BuildContext context) {
  return StreamComponentFactory(
    builders: componentBuilders,
    child: AIConversationView(messages: messages),
  );
}
```

`AIUserMessageView` shows the message's attachments, then its text in a bubble at the trailing edge. `AIAssistantMessageView` shows the reply's steps with `AIMessagePartsView`, then its answer with `StreamingMessageView`, then its attachments. You can use both views outside the conversation as well.

The conversation caches its rows. A row is built again only when what its `AIMessage` says changes, or the builders do, so a streaming reply doesn't rebuild the rest of the conversation. Two rules follow for builders that show state of your own, such as a reply's rating or a message's send status:

- Read that state through a `Listenable`, with a `ListenableBuilder` or `ValueListenableBuilder` in what the builder returns. A value captured directly isn't read again when it changes, so a tapped thumbs up wouldn't fill.
- Create the builders and their `StreamComponentBuilders` once, such as in `late final` fields of your `State`, as above, not in `build`. A new `StreamComponentBuilders` rebuilds every row whenever your widget rebuilds, such as on every update of a streaming reply. The same goes for the `approver` and `onAttachmentTap` you pass to `AIConversationView`.

The nearest `StreamComponentFactory` wins: a nested one replaces its parent's builders rather than adding to them. To keep your app's other builders, such as the Stream Chat ones, list them in the same `extensions` list.

### Reply actions

`AIReplyActionsView` is a row of buttons under a finished reply. While the reply has text, it offers to copy and share it. Give its `AIReplyActions` an `onRegenerate` callback to offer regenerating the reply, and `rating` and `onRate` to let the user rate it.

A conversation view that shows an [`AIConversation`](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#actions) shows the row by default, with the conversation's actions, which its `replyActions` builder finds in `props.actions`. Otherwise there's nothing under a reply by default: return the row from the `replyActions` builder, with actions of your own. The rating changes without the reply changing, so read it through a `ValueNotifier`:

```dart
// In your State:
final ratings = ValueNotifier<Map<String, AIMessageRating>>({});

late final builders = streamAIComponentBuilders(
  replyActions: (context, props) => ValueListenableBuilder(
    valueListenable: ratings,
    builder: (context, rated, _) => AIReplyActionsView(
      text: props.text,
      actions: AIReplyActions(
        rating: rated[props.message.id],
        onRate: (rating) => rate(props.message, rating),
        onRegenerate: props.isLatestReply ? regenerate : null,
      ),
    ),
  ),
);

void rate(AIMessage reply, AIMessageRating? rating) {
  ratings.value = {
    for (final MapEntry(:key, :value) in ratings.value.entries)
      if (key != reply.id) key: value,
    reply.id: ?rating,
  };
  // Send the rating to your backend.
}
```

The actions show once the reply is finished and its answer has typed itself out, also under a reply that answers with [generative UI](#generative-ui) or attachments and has no text, once its UI is written. With an `assistantMessage` builder that doesn't use `AIAssistantMessageView`, they show as soon as the reply stops generating. `isLatestReply` tells the latest reply apart, for example to offer regenerating only that one.

- **Regenerate** shows only when `onRegenerate` is set. With an `AIConversation`, it asks the agent to write the reply again. Without one, what it does, such as asking your backend for a new reply, is up to your app.
- **Rate** shows a thumbs up and a thumbs down only when `onRate` is set. `onRate` receives `AIMessageRating.positive` or `AIMessageRating.negative`, or `null` when the user taps their rating again to take it back. Pass the rating given as `rating`, and its thumb fills.

### Editing a message

Pressing and holding the user's message, or right-clicking it, offers to copy its text, and to edit it when `AIUserMessageView` gets an `onEdit`. With an `AIConversation`, the props' `onEdit` puts the user's latest message back in the composer, and the default builder passes it on. Without one, the default builder passes none, so set `userMessage`. Here editing puts the text back in the composer:

```dart
final composer = ChatComposerController();

late final builders = streamAIComponentBuilders(
  userMessage: (context, props) => AIUserMessageView(
    text: props.text,
    attachments: props.attachments,
    onAttachmentTap: props.onAttachmentTap,
    onEdit: props.message == null
        ? null
        : () => composer.textEditingController.text = props.text,
  ),
);

@override
void dispose() {
  composer.dispose();
  super.dispose();
}
```

Pass the same `composer` to your `ChatComposer` as its `controller`. To put the cursor in the field too, call `composer.requestFocus()` in `onEdit`, as described in [Control it from your code](https://getstream.io/chat/docs/sdk/flutter/ai-components/composer/#control-it-from-your-code). A message that is still on its way has no `message`, so it offers no edit. Without a conversation, what sending the edited text does, such as updating the message and asking for a new reply, is up to your app.

## Attachments

An `AIMessageAttachment` is something a message carries besides its text. `AIMessageAttachmentView`, the default, shows an image as a square thumbnail, 180 wide unless you set its `imageSize`, and any other kind as a file card with its name, type and size. Tapping a file opens it in an app for its type, and a page on the web in the browser:

```dart
final attachments = [
  AIMessageAttachment.image(imageUrl),
  AIMessageAttachment.memoryImage(ResizeImage(MemoryImage(photoBytes), width: 360)),
  AIMessageAttachment.file(reportUrl, name: 'Report.pdf', size: 2400000),
];
```

- `AIMessageAttachment.image` loads an image from a URL.
- `AIMessageAttachment.memoryImage` takes an `ImageProvider` your app holds, such as a photo the user just took. Pass a small one, the size it shows at, since it stays in memory while the conversation shows it.
- `AIMessageAttachment.file` takes the file's URL, and optionally its `name`, `size` in bytes and `mimeType`. The card shows the `name`, or else the last segment of the URL. Its type is the extension of the name or URL, or else the subtype of `mimeType`, such as `PDF`.

To open attachments your own way, pass `onAttachmentTap` to the conversation:

```dart
void openAttachment(AIMessageAttachment attachment) {
  if (attachment.url case final url?) FilePreview.show(context, url);
}

@override
Widget build(BuildContext context) {
  return AIConversationView(
    messages: messages,
    onAttachmentTap: openAttachment,
  );
}
```

Here `FilePreview` is a widget of your app.

A method of your `State`, as above, is the same function on every build, so the rows aren't built again. A closure written in `build` is a new one each time.

The kinds of attachments, `AIMessageAttachmentKind`, are an open set, so a new one can appear without breaking your code. `AIMessageAttachmentView` shows any kind but `image` as a file, and an image with neither a `url` nor an `image` provider as a file too. To show kinds of your own, such as a location, keep what they need in `extraData`. With Stream Chat, add a case for the kind to `toAIMessageAttachment` from [Messages from Stream Chat](#messages-from-stream-chat):

```dart
Attachment(type: 'location') => AIMessageAttachment(
  kind: const AIMessageAttachmentKind('location'),
  extraData: attachment.extraData,
),
```

Then render the kind in the `messageAttachment` builder, and return the default view for every other kind:

```dart
late final builders = streamAIComponentBuilders(
  messageAttachment: (context, props) => switch (props.attachment.kind) {
    'location' => LocationCard(extraData: props.attachment.extraData),
    _ => AIMessageAttachmentView(attachment: props.attachment, onTap: props.onTap),
  },
);
```

`props.isFromUser` says whether the user or the agent sent the message. `extraData` compares by value, so an attachment decoded again on the next update doesn't rebuild its row.

## Generative UI

Pass an `AIUIHost` as `ui`. When one of the host's renderers can read a reply's `uiDocument`, the view shows that UI in place of the reply's text, after its steps, such as a trip plan with a map and a form. What the user does in it goes back to the agent as their next message, through the host's `send`. The [Generative UI](https://getstream.io/chat/docs/sdk/flutter/ai-components/generative-ui/) guide covers the renderers, your own components and what to send.

```dart
AIConversationView(messages: messages, ui: ui)
```

Keep the same host for as long as the conversation shows, so each reply's UI keeps its state. A reply whose UI none of the host's renderers reads shows its text.

A conversation that an `AIConversation` drives takes the host the same way, as `AIConversationView.conversation(conversation: conversation, ui: ui)`. See [Conversation State](https://getstream.io/chat/docs/sdk/flutter/ai-components/conversation-state/#generative-ui).

The thinking indicator gives way to a reply's UI as soon as it shows. If you compute `isThinking` with a helper such as `replyShowsNothing` from [Show the agent thinking](#show-the-agent-thinking), count a reply with a `uiDocument` as showing something too. The reply actions show under a reply that answers only with UI, once its UI is written, and the `footer` and `emptyContent` work the same.

## 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/flutter/ai-components/reasoning-and-tool-calls/#tool-approvals) covers both.

```dart
AIConversationView(messages: messages, approver: approver)
```

Create the approver once, for example in your `State`, since a new one rebuilds every row.

## Appearance

The conversation takes its colors from the Stream theme, `StreamTheme`. To change them, set `conversationTheme` and `replyActionsTheme` on the `AITheme` extension, as described in [Theming](https://getstream.io/chat/docs/sdk/flutter/ai-components/#theming):

```dart
MaterialApp(
  theme: ThemeData(
    extensions: const [
      AITheme(
        conversationTheme: ConversationThemeData(
          userMessageBackgroundColor: Color(0xFFE8F0FF),
          scrollButtonIconColor: Color(0xFF005FFF),
        ),
        replyActionsTheme: ReplyActionsThemeData(
          selectedIconColor: Color(0xFF005FFF),
        ),
      ),
    ],
  ),
  home: const ConversationScreen(),
)
```

`ConversationThemeData` covers `AIConversationView`, `AIUserMessageView` and `AIMessageAttachmentView`. Every field is nullable, and a field left unset follows the `StreamTheme`:

| Field                         | Styles                                                          | Default                                                    |
| ----------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------- |
| `userMessageBackgroundColor`  | The user's bubble.                                              | `backgroundSurface`                                        |
| `userMessageTextStyle`        | The text in the user's bubble, merged onto the default.         | `bodyDefault` in `textPrimary`                             |
| `attachmentBackgroundColor`   | A file card, and an image while it loads or when it fails to.   | `backgroundSurface`                                        |
| `attachmentTitleStyle`        | A file card's name, merged onto the default.                    | `bodyEmphasis` in `textPrimary`                            |
| `attachmentDetailsStyle`      | A file card's type and size, merged onto the default.           | `captionDefault` in `textSecondary`                        |
| `scrollButtonBackgroundColor` | The button that scrolls to the latest message.                  | the secondary outline button's style                       |
| `scrollButtonIconColor`       | That button's arrow, and the spinner while older messages load. | the button's foreground, and `textPrimary` for the spinner |
| `scrollButtonBorderColor`     | That button's border.                                           | the secondary outline button's style                       |

`ReplyActionsThemeData` covers `AIReplyActionsView`:

| Field               | Styles                         | Default         |
| ------------------- | ------------------------------ | --------------- |
| `iconColor`         | The buttons' icons.            | `textSecondary` |
| `selectedIconColor` | The thumb of the rating given. | `iconColor`     |
| `iconSize`          | The size of the icons.         | `18`            |

To theme one conversation only, wrap it in a `ConversationTheme`, or the reply actions in a `ReplyActionsTheme`:

```dart
ConversationTheme(
  data: const ConversationThemeData(userMessageBackgroundColor: Colors.teal),
  child: AIConversationView(messages: messages),
)
```

A nested scope replaces the enclosing one. Use `ConversationTheme.merge` or `ReplyActionsTheme.merge` to add to it instead.

Its texts, such as "Scroll to latest", "Copy" or "Regenerate", are members of `AITranslations`, which you translate as described in [Localization](https://getstream.io/chat/docs/sdk/flutter/ai-components/#localization).

---

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