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.
AIConversationView is part of StreamChatAI, available from version 5.15.0 of the Stream Chat Swift SDK. See Installation.
Show a conversation
Conform your messages to AIMessage. A message needs an id, a role and its text. The other requirements have defaults:
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:
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 the agent took for it, in order. | [] |
attachments |
What it carries besides its text, such as images and files. See 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 and Best practices:
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:
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.
AIConversationView(
messages: messages,
isThinking: indicatorHandler.indicatorText != nil
)Here indicatorHandler is the handler from 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:
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:
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:
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:
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:
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:
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:
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 AIMessages 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.
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:
@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 covers both.
AIConversationView(messages: messages, approver: approver)Appearance
The conversation view reads its colors, fonts and icons from AIAppearance, as described in Customizing the appearance:
- Colors:
userMessageBackgroundanduserMessageTextfor the user's bubble,messageActionfor the reply actions,conversationLoadingIndicatorfor the spinner while older messages load,conversationScrollButtonBackground,conversationScrollButtonIconandconversationScrollButtonBorderfor the button that scrolls to the end, andattachmentImagePlaceholder,attachmentBackground,attachmentBorder,attachmentIcon,attachmentIconBackground,attachmentNameandattachmentDetailsfor attachments. - Fonts:
userMessage,messageAction,conversationScrollButton,attachmentIcon,attachmentNameandattachmentDetails. - Icons:
messageCopy,messageCopied,messageShare,messageRegenerate,messageEdit,messageRatePositive,messageRatedPositive,messageRateNegativeandmessageRatedNegativefor the message actions,attachmentFile,attachmentPDF,attachmentImage,attachmentAudio,attachmentVideoandattachmentArchivefor the types of files, andconversationScrollToLatest.
Its texts, such as "Scroll to latest", "Copy" or "Regenerate", go through AIAppearance.localizationProvider, under the conversation.*, message_actions.* and attachment.* keys.