# 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's lighter than the message list of StreamChatSwiftUI, which it doesn't need.

>
> **Info:** `AIConversationView` is part of StreamChatAI, available from version 5.15.0 of the Stream Chat Swift SDK. See [Installation](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#installation).
>

## Show a conversation

Conform your messages to `AIMessage`. A message needs an `id`, a `role` and its `text`. The other requirements have defaults:

```swift
struct Message: AIMessage {
    var id: String
    var role: AIMessageRole
    var text: String
    var isGenerating = false
}
```

Then pass them to the view, oldest first, with your composer below it:

```swift
VStack(spacing: 0) {
    AIConversationView(messages: messages)
    AIComposerView { message in
        send(message)
    }
}
```

You can also place the composer over the list with `safeAreaInset(edge: .bottom)`, so the conversation scrolls under it. Either way, the list knows how much of it is visible.

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

| Requirement           | Description                                                                                                                    | Default |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------- |
| `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/ios/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(for:)` | The whole of a reasoning step's thinking, when your app has it, such as from live updates. Otherwise the step's excerpt shows. | `nil`   |

### Messages from Stream Chat

To show a Stream Chat channel, wrap its messages. The reply's steps and its `generating` field come from the message, as described in [Reasoning and Tool Calls](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/reasoning-and-tool-calls/) and [Best practices](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#mark-assistant-messages-with-ai_generated):

```swift
struct ChatAIMessage: AIMessage {
    let message: ChatMessage

    var id: MessageId { message.id }
    var role: AIMessageRole { message.isSentByCurrentUser ? .user : .assistant }
    var text: String { message.text }
    var isGenerating: Bool { message.extraData["generating"]?.boolValue == true }

    var parts: [AIMessagePart] {
        AIMessagePart.parts(from: message.allAttachments.map { ($0.type.rawValue, $0.payload) })
    }

    var attachments: [AIMessageAttachment] {
        message.imageAttachments.map { .image($0.imageURL) }
            + message.fileAttachments.map { .file($0.assetURL, name: $0.title, size: $0.file.size) }
    }
}
```

A channel controller lists its messages newest first, so reverse them:

```swift
AIConversationView(
    messages: channelController.messages.reversed().map(ChatAIMessage.init)
)
```

## Show the agent thinking

Before a reply shows anything, pass `isThinking: true`. The latest turn then ends with an `AIThinkingView`, "Thinking... 4s", counting from when the question was asked. Once the reply's first reasoning step arrives, it 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.

```swift
AIConversationView(
    messages: messages,
    isThinking: indicatorHandler.indicatorText != nil
)
```

Here `indicatorHandler` is the handler from [Drive the AI indicator from events](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#drive-the-ai-indicator-from-events). The list also knows the agent is thinking while a reply is being written but shows nothing yet, and while a message the user sent is on its way, as described next.

`AIThinkingView` works on its own too. Outside the conversation view it counts from its `since` date, 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 or its photos upload. Pass it as the `pendingMessage`, and it shows as theirs straight away:

```swift
AIConversationView(
    messages: messages,
    pendingMessage: sending
)

AIComposerView { message in
    sending = AIPendingMessage(
        text: message.text,
        attachments: message.attachments.map { AIMessageAttachment(url: $0) }
    )
    send(message)
}
```

Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, with its attachments, and the list doesn't move. If you know the id the message will have, pass it as the pending message's `id`, so the copy is found by its id rather than by where it lands.

## Load older messages

Pass `loadOlderMessages` to load the messages before the first one. The list 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 `nil` once there are none left:

```swift
AIConversationView(
    messages: messages,
    loadOlderMessages: hasOlderMessages ? { await loadOlderMessages() } : nil
)
```

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

```swift
AIConversationView(
    messages: messages,
    isThinking: isWaitingForReply,
    footer: {
        if let error {
            RetryButton(error: error)
        }
    },
    emptyContent: {
        Text("How can I help?")
    }
)
```

## Customize the messages

The conversation view shows its messages with the views of an `AIConversationViewFactory`. Every method has a default, so override only the ones you want to change:

| Factory method                        | Shows                                                 | Default                   |
| ------------------------------------- | ----------------------------------------------------- | ------------------------- |
| `makeUserMessageView(options:)`       | A message the user sent.                              | `AIUserMessageView`       |
| `makeAssistantMessageView(options:)`  | A reply: its steps, its answer and its attachments.   | `AIAssistantMessageView`  |
| `makeReplyActionsView(options:)`      | What the user can do with a finished reply, under it. | Nothing                   |
| `makeAttachmentView(options:)`        | One of a message's attachments.                       | `AIMessageAttachmentView` |
| `makeToolAuthorizationView(options:)` | The sign-in a tool call waits for.                    | `AIToolAuthorizationCard` |

Pass your factory to the view:

```swift
AIConversationView(messages: messages, viewFactory: MyFactory(chat: chat))
```

Each method's options carry what the view shows, and the message views' options carry your `message` too. It's one of your own messages, so cast it to read what else it holds.

`AIUserMessageView` shows the message's attachments, then its text in a bubble at the trailing edge. Pressing the bubble offers to copy the text, and to edit it when you pass `onEdit`, for example to put the text back in the composer:

```swift
func makeUserMessageView(options: AIUserMessageViewOptions) -> some View {
    AIUserMessageView(viewFactory: self, text: options.text, attachments: options.attachments) {
        composerViewModel.text = options.text
        composerViewModel.isTextFieldFocused = true
    }
}
```

`AIAssistantMessageView` shows the reply's steps with `AIMessagePartsView`, then its answer with `StreamingMessageView`, then its attachments. You can use both views outside the conversation view as well.

### Reply actions

There's nothing under a finished reply by default. Return an `AIReplyActionsView` from `makeReplyActionsView(options:)` to add a row of buttons. It always offers to copy the reply, and to share it on iOS 16 and later. Give its `AIReplyActions` an `onRegenerate` closure to offer regenerating the reply, and `rating` and `onRate` to let the user rate it:

```swift
final class MyFactory: AIConversationViewFactory {
    let chat: Chat

    init(chat: Chat) {
        self.chat = chat
    }

    func makeReplyActionsView(options: AIReplyActionsViewOptions) -> some View {
        AIReplyActionsView(
            text: options.text,
            actions: AIReplyActions(onRegenerate: options.isLatestReply ? { chat.regenerate() } : nil)
        )
    }
}
```

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. The row of a reply isn't made again while the reply shows the same, so read what changes, such as the user's rating, from an observable model in a view of your own, rather than capturing it in the factory. `onRate` receives `.positive` or `.negative`, or `nil` when the user takes their rating back.

### Rows of your own

To show messages of any kind your own way, pass a closure that makes each row, and say which messages the user sent, since each of them starts a turn:

```swift
AIConversationView(messages: messages, isSentByCurrentUser: \.isSentByCurrentUser) { message in
    MessageRow(message: message)
        .equatable()
}
```

These rows are laid out again each time the messages change. Make the row's view `Equatable` and add `.equatable()`, as above, so only the message that changed is laid out again while a reply streams. The views that show `AIMessage`s already do this.

## Attachments

An `AIMessageAttachment` is something a message carries besides its text. `AIMessageAttachmentView`, the default, shows an image as a thumbnail, and any other kind as a file card with its name, type and size. Tapping a file opens it: a file on the device in a preview, and one on the web through the `openURL` environment action, which you can set to open it your own way.

```swift
let attachments: [AIMessageAttachment] = [
    .image(imageURL),
    .image(capturedPhoto),
    .file(reportURL, name: "Report.pdf", size: 2_400_000)
]
```

`.image` takes a URL on the web or on the device, or a `UIImage` 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(url:name:size:mimeType:)` picks the kind from the file's type, which suits the files the user picked in the composer.

The kinds of attachments are an open set, so a new one can appear without breaking your code. A kind the view doesn't know shows as a file. To show kinds of your own, keep what they need in `extraData`, and return their view from `makeAttachmentView(options:)`, with the default for the rest:

```swift
@ViewBuilder
func makeAttachmentView(options: AIMessageAttachmentViewOptions) -> some View {
    if options.attachment.kind == "location" {
        LocationCard(extraData: options.attachment.extraData)
    } else {
        AIMessageAttachmentView(attachment: options.attachment)
    }
}
```

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

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

## Appearance

The conversation view reads its colors, fonts and icons from `AIAppearance`, as described in [Customizing the appearance](https://getstream.io/chat/docs/sdk/ios/guides/ai-integrations/#customizing-the-appearance):

- **Colors:** `userMessageBackground` and `userMessageText` for the user's bubble, `messageAction` for the reply actions, `conversationLoadingIndicator` for the spinner while older messages load, `conversationScrollButtonBackground`, `conversationScrollButtonIcon` and `conversationScrollButtonBorder` for the button that scrolls to the end, and `attachmentImagePlaceholder`, `attachmentBackground`, `attachmentBorder`, `attachmentIcon`, `attachmentIconBackground`, `attachmentName` and `attachmentDetails` for attachments.
- **Fonts:** `userMessage`, `messageAction`, `conversationScrollButton`, `attachmentIcon`, `attachmentName` and `attachmentDetails`.
- **Icons:** `messageCopy`, `messageCopied`, `messageShare`, `messageRegenerate`, `messageEdit`, `messageRatePositive`, `messageRatedPositive`, `messageRateNegative` and `messageRatedNegative` for the message actions, `attachmentFile`, `attachmentPDF`, `attachmentImage`, `attachmentAudio`, `attachmentVideo` and `attachmentArchive` for the types of files, and `conversationScrollToLatest`.

Its texts, such as "Scroll to latest", "Copy" or "Regenerate", go through `AIAppearance.localizationProvider`, under the `conversation.*`, `message_actions.*` and `attachment.*` keys.

---

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