# 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 message list of the Compose UI components.

>
> **Info:** `AIConversation` is available from version 7.16.0 of the `stream-chat-android-ai-compose` artifact. See [Installation](https://getstream.io/chat/docs/sdk/android/guides/ai-integrations/#installation).
>

## Show a conversation

Implement `AIMessage` on your own message type. A message needs an `id`, a `role` and its `text`. The other properties have defaults:

```kotlin
data class ChatMessage(
    override val id: String,
    override val role: AIMessageRole,
    override val text: String,
    override val isGenerating: Boolean = false,
) : AIMessage
```

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

```kotlin
Column(modifier = Modifier.fillMaxSize()) {
    AIConversation(
        messages = messages,
        modifier = Modifier.weight(1f),
    )
    ChatComposer(
        onSendClick = { data -> send(data) },
        onStopClick = { stop() },
        isGenerating = isGenerating,
    )
}
```

Place the composer below the list rather than over it, so 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 properties:

| Property              | 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 `StreamingText`.                       | `false`       |
| `parts`               | The [steps](https://getstream.io/chat/docs/sdk/android/guides/ai-integrations/reasoning-and-tool-calls/) the agent took for it, in order.          | `emptyList()` |
| `attachments`         | What it carries besides its text, such as images and files. See [Attachments](#attachments).                                   | `emptyList()` |
| `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 updates, so look the text up rather than work it out.

### Messages from Stream Chat

The SDK has no ready-made `AIMessage` for a Stream Chat `Message`, so implement one in your app. 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/android/guides/ai-integrations/reasoning-and-tool-calls/) and [Best practices](https://getstream.io/chat/docs/sdk/android/guides/ai-integrations/#mark-assistant-messages-with-ai_generated):

```kotlin
class StreamAIMessage(
    val message: Message,
    currentUserId: String,
) : AIMessage {

    override val id: String = message.id

    override val role: AIMessageRole =
        if (message.user.id == currentUserId) AIMessageRole.User else AIMessageRole.Assistant

    override val text: String = message.text

    override val isGenerating: Boolean = message.extraData["generating"] == true

    // Stream Chat moves an attachment's `name` out of its extraData. A tool call's step needs it back.
    override val parts: List<AIMessagePart> = AIMessagePart.parts(
        message.attachments.map { it.type.orEmpty() to it.extraData + ("name" to it.name) },
    )

    override val attachments: List<AIMessageAttachment> =
        message.attachments.mapNotNull { it.toAIMessageAttachment() }
}

private fun Attachment.toAIMessageAttachment(): AIMessageAttachment? = when (type) {
    AttachmentType.IMAGE -> imageUrl?.let { AIMessageAttachment.image(Uri.parse(it), name = name) }
    AttachmentType.FILE -> assetUrl?.let {
        AIMessageAttachment.file(Uri.parse(it), name = title ?: name, size = fileSize.toLong(), mimeType = mimeType)
    }
    else -> null
}
```

The properties are read on every update while a reply streams, so the wrapper decodes the steps once, when it's created, rather than in a getter. The conversation hands your message back in the params of the [component factory](#customize-the-views)'s message slots, so you can cast `params.message` to `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:

```kotlin
class ConversationViewModel(cid: String, currentUserId: String) : ViewModel() {

    private val channelState = ChatClient.instance()
        .watchChannelAsState(cid = cid, messageLimit = 30, coroutineScope = viewModelScope)

    @OptIn(ExperimentalCoroutinesApi::class)
    val messages: StateFlow<List<AIMessage>> = channelState
        .filterNotNull()
        .flatMapLatest { it.messages }
        .map { messages -> messages.map { StreamAIMessage(it, currentUserId) } }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
```

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

```kotlin
// The latest reply shows nothing yet: there is none, or it has no text, steps or attachments.
val replyShowsNothing = messages.lastOrNull()?.let { last ->
    last.role == AIMessageRole.User ||
        (last.text.isEmpty() && last.parts.isEmpty() && last.attachments.isEmpty())
} ?: true

AIConversation(
    messages = messages,
    isThinking = indicator != null && replyShowsNothing,
)
```

Here `indicator` is the state from [Drive the AI indicator from events](https://getstream.io/chat/docs/sdk/android/guides/ai-integrations/#drive-the-ai-indicator-from-events), which stays set until `ai_indicator.clear`, while the answer streams too. 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.

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

```kotlin
val scope = rememberCoroutineScope()
var sending by remember { mutableStateOf<AIPendingMessage?>(null) }

AIConversation(
    messages = messages,
    pendingMessage = sending,
)

ChatComposer(
    onSendClick = { data ->
        val id = UUID.randomUUID().toString()
        sending = AIPendingMessage(
            id = id,
            text = data.text,
            attachments = data.attachments.map { uri ->
                AIMessageAttachment.from(uri, mimeType = context.contentResolver.getType(uri))
            },
        )
        scope.launch {
            val attachments = data.attachments.map { uri -> context.attachmentToUpload(uri) }
            channelClient.sendMessage(Message(id = id, text = data.text, attachments = attachments))
                .await()
                .onError { sending = null }
        }
    },
    onStopClick = { stop() },
    isGenerating = isGenerating,
)
```

Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, and the list 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: 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.

Stream Chat uploads an attachment from a `File`, so copy each file the user picked to the cache first:

```kotlin
suspend fun Context.attachmentToUpload(uri: Uri): Attachment = withContext(Dispatchers.IO) {
    val file = File(cacheDir, "upload-${UUID.randomUUID()}")
    checkNotNull(contentResolver.openInputStream(uri)).use { input ->
        file.outputStream().use { input.copyTo(it) }
    }
    Attachment(upload = file, mimeType = contentResolver.getType(uri))
}
```

`AIMessageAttachment.from` picks an image or a file from the file's type. A photo picker or MediaStore `content://` URI has no extension, so pass `contentResolver.getType(uri)` as its `mimeType`, or it shows as a file.

## 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 `null` once there are none left:

```kotlin
AIConversation(
    messages = messages,
    loadOlderMessages = if (hasOlderMessages) viewModel::loadOlderMessages else null,
)
```

With Stream Chat, `loadOlderMessages` can call `ChatClient.instance().loadOlderMessages(cid, messageLimit = 30).await()`, and `hasOlderMessages` is the negation of the channel state's `endOfOlderMessages`.

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

```kotlin
AIConversation(
    messages = messages,
    isThinking = isWaitingForReply,
    footer = {
        if (error != null) {
            RetryButton(error = error, onRetry = retry)
        }
    },
    emptyContent = {
        Text("How can I help?")
    },
)
```

## Customize the views

The conversation draws each message through `ChatAiComponentFactory`, which you provide with `LocalChatAiComponentFactory`. Every slot has a default, so override only the ones you want to change:

| Slot                             | Shows                                                             | Default                   |
| -------------------------------- | ----------------------------------------------------------------- | ------------------------- |
| `AIConversationUserMessage`      | A message the user sent, or one on its way (`message` is `null`). | `AIUserMessage`           |
| `AIConversationAssistantMessage` | A reply: its steps, its answer and its attachments.               | `AIAssistantMessage`      |
| `AIConversationReplyActions`     | What the user can do with a finished reply.                       | Nothing                   |
| `AIConversationAttachment`       | One attachment of a message.                                      | `AIMessageAttachmentItem` |

The params of the user message, assistant message and reply actions slots carry your own message as `message`, so cast it to read what else it holds. `AIConversationAttachment` gets the `attachment` and the sender's `role` instead. To override some slots on top of the factory already provided, wrap the content in `CompoundChatAiComponentFactory`, as in [Editing a message](#editing-a-message).

### Reply actions

`AIReplyActionsRow` shows copy and share buttons under a reply, and regenerate and rate buttons when you handle them. The conversation shows nothing there by default, so return the row from `AIConversationReplyActions`:

```kotlin
class ReplyActionsFactory(
    private val ratings: Map<String, AIMessageRating>,
    private val onRate: (messageId: String, rating: AIMessageRating?) -> Unit,
    private val onRegenerate: () -> Unit,
) : ChatAiComponentFactory {

    @Composable
    override fun AIConversationReplyActions(params: AIConversationReplyActionsParams) {
        AIReplyActionsRow(
            text = params.text,
            actions = AIReplyActions(
                rating = ratings[params.message.id],
                onRegenerate = if (params.isLatestReply) onRegenerate else null,
                onRate = { rating -> onRate(params.message.id, rating) },
            ),
            modifier = params.modifier,
        )
    }
}
```

```kotlin
val ratings = remember { mutableStateMapOf<String, AIMessageRating>() }
val factory = remember {
    ReplyActionsFactory(
        ratings = ratings,
        onRate = { id, rating -> if (rating == null) ratings.remove(id) else ratings[id] = rating },
        onRegenerate = { /* Ask your backend for a new reply. */ },
    )
}
CompositionLocalProvider(LocalChatAiComponentFactory provides factory) {
    AIConversation(messages = messages)
}
```

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 `AIMessageRating.Positive` or `AIMessageRating.Negative`, or `null` when the user taps their rating again to take it back. Keep the ratings in snapshot state, as above, so the row updates when one changes.

### Editing a message

Pressing and holding the user's message offers to copy its text, and to edit it when `AIUserMessage` gets an `onEdit`. The default slot passes none, so override it. Here editing puts the text back in a composer whose message you own:

```kotlin
var draft by remember { mutableStateOf(MessageData()) }

Column {
    CompoundChatAiComponentFactory(
        factory = { current ->
            object : ChatAiComponentFactory by current {
                @Composable
                override fun AIConversationUserMessage(params: AIConversationUserMessageParams) {
                    val message = params.message
                    AIUserMessage(
                        text = params.text,
                        attachments = params.attachments,
                        modifier = params.modifier,
                        onEdit = message?.let { { draft = MessageData(text = it.text) } },
                    )
                }
            }
        },
    ) {
        AIConversation(messages = messages, modifier = Modifier.weight(1f))
    }
    ChatComposer(
        messageData = draft,
        onMessageDataChange = { draft = it },
        onSendClick = { data -> send(data) },
        onStopClick = { stop() },
        isGenerating = isGenerating,
    )
}
```

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.

### Rows of your own

To show messages of any type your own way, use the overload that takes a `key`, says which messages the user sent (each of them starts a turn), and makes each row:

```kotlin
AIConversation(
    messages = rows,
    key = { it.id },
    isFromUser = { it.isMine },
    isThinking = isWaitingForReply,
) { row ->
    MessageRow(row.text)
}
```

The key must be stable, unique and saveable in a `Bundle`, as with any lazy list key. This overload doesn't take a `pendingMessage` or an `approver`, since your rows show the messages.

## Attachments

An `AIMessageAttachment` is something a message carries besides its text. `AIMessageAttachmentItem`, the default, shows an image as a square thumbnail, 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.

```kotlin
val attachments = listOf(
    AIMessageAttachment.image(imageUri),
    AIMessageAttachment.image(capturedPhoto),
    AIMessageAttachment.file(reportUri, name = "Report.pdf", size = 2_400_000),
)
```

`image` takes a URI on the web or on the device, or an `ImageBitmap` your app holds, such as a photo the user just took. Pass a small bitmap, the size it shows at, since it stays in memory while the conversation shows it. `AIMessageAttachment.from(uri, name, size, mimeType)` picks the kind from the file's type, which suits the files the user picked in the composer. JPEG, PNG, WebP, GIF and BMP images show on every Android version, HEIF and HEIC from Android 9, and AVIF from Android 12. Other images, such as SVG, show as files.

The kinds of attachments are an open set, so a new one can appear without breaking your code. A kind the item doesn't know shows as a file. To show kinds of your own, keep what they need in `extraData`, and render them in `AIConversationAttachment`, with the default for the rest:

```kotlin
class LocationAttachmentFactory : ChatAiComponentFactory {

    @Composable
    override fun AIConversationAttachment(params: AIConversationAttachmentParams) {
        if (params.attachment.kind == AIMessageAttachment.Kind("location")) {
            LocationCard(extraData = params.attachment.extraData)
        } else {
            super.AIConversationAttachment(params)
        }
    }
}
```

`params.role` says whether the user or the agent sent the message.

## Tool calls

Pass an `AIToolApprover` as the `approver`, and the conversation asks the user about tool calls that wait for their approval. [Reasoning and Tool Calls](https://getstream.io/chat/docs/sdk/android/guides/ai-integrations/reasoning-and-tool-calls/#tool-approvals) covers it.

```kotlin
AIConversation(messages = messages, approver = approver)
```

## Colors and defaults

The views take their colors from the current `MaterialTheme`. Each one has a `Defaults` object whose `colors()` (and `icons()`, where it has icons) you can change one value at a time:

- `AIConversation`: `colors = AIConversationDefaults.colors(...)` with `scrollButtonBackground`, `scrollButtonBorder`, `scrollButtonIcon` and `loadingIndicator`, and `icons = AIConversationDefaults.icons(scrollToLatest = ...)`.
- `AIUserMessage`: `AIUserMessageDefaults.colors(background, text)` for the user's bubble.
- `AIReplyActionsRow`: `AIReplyActionsDefaults.colors(icon)`, and `AIReplyActionsDefaults.icons(...)` with `copy`, `copied`, `share`, `regenerate`, `ratePositive`, `ratedPositive`, `rateNegative` and `ratedNegative`.
- `AIMessageAttachmentItem`: `AIMessageAttachmentDefaults.colors(...)` with `imagePlaceholder`, `background`, `border`, `iconBackground`, `icon`, `name` and `details`, `AIMessageAttachmentDefaults.icons(...)` with `file`, `pdf`, `image`, `audio`, `video` and `archive`, and an `imageSize` of 180 dp by default.

```kotlin
AIConversation(
    messages = messages,
    colors = AIConversationDefaults.colors(
        scrollButtonBackground = Color(0xFF1E1E2E),
        scrollButtonIcon = Color.White,
    ),
)
```

To change the colors of the user's bubble, the reply actions or the attachments inside the conversation, override their slot and pass the colors to the view there. The texts, such as "Scroll to latest", "Copy" or "Regenerate", are string resources whose names start with `stream_ai_compose_conversation_`, which you can override in your app's `strings.xml`.

---

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