Pattern · AI Chat

New

Chat Thread SwiftUI Block

The message list of an assistant chat: replies in light markdown that stream in with Stop, then Copy, Regenerate, rate and Share.

The thread of an assistant conversation. The person's turns sit on the fill and replies are plain text with paragraphs, bold, lists, inline code and code blocks with Copy, streamed from an AsyncThrowingStream your app provides, with Stop. Finished replies get Copy, Regenerate, thumbs up and down and Share; a failed reply shows its error with Retry, and the thread exposes an anchor so your scroll view can follow the stream or offer Jump to latest.

  • SwiftUI
  • iOS 17.0+
  • chat-thread
Get SwiftPieces Pro

$199 once, every piece included. See how installing works · Sign in

01

What's included

SwiftUI source you own, built on Apple frameworks only.

  • Replies stream in from your AsyncThrowingStream, with Thinking and Stop
  • Light markdown: paragraphs, bold, bullet lists, inline code and code blocks with Copy
  • Copy that answers Copied, Regenerate, thumbs up and down, and Share
  • A failed reply shows its error in place with Retry
  • Day separators, ghost rows while loading, and follow-the-stream with Jump to latest

02

Implementation notes

How it behaves, what it handles, and the decisions behind it.

The person's turns sit on the right on the theme's fill; the assistant's replies are plain text with light markdown: paragraphs, semibold bold, bullet lists, inline code and fenced code blocks that carry their language and a Copy button. Append the person's message to the thread and the block asks your reply closure for an AsyncThrowingStream of text, adds the assistant's message and writes each chunk into it, with "Thinking" before the first chunk and Stop under the text while it streams. Stop keeps what arrived and marks it Stopped. Every finished reply gets an action row: Copy, which answers "Copied" for a moment, Regenerate on the latest reply, thumbs up and down with one selected state, and Share through ShareLink. A stream that throws shows its error in place with Retry. Day separators read Today, Yesterday or the date. An empty thread draws nothing, so your own welcome can show, and isLoading draws ghost turns in the thread's layout.

Notes

  • ChatThreadBlock(messages:isLoading:atLatest:reply:onFeedback:) takes a binding to the thread, oldest first. It is a section with no scroll view of its own: it lays the thread out as a LazyVStack for your ScrollView.
  • ChatThreadBlock.Message has an id, a role (.user, .assistant), text (markdown for replies), a date, a state (.complete, .streaming, .stopped, .failed(String)) and an optional feedback (.good, .bad). Message.sample is the question and answer from the preview.
  • reply receives every message before the reply and returns an AsyncThrowingStream<String, any Error> of chunks. The block starts it whenever the thread ends with the person's turn, and again for Regenerate and Retry, replacing that reply in place. Throw from the stream to show the failed state with the error's errorDescription, or "The reply didn't finish. Try again." Your binding always holds the thread as shown, so save it from there.
  • onFeedback runs when a thumb is chosen or cleared, after the choice shows. Throw to put the previous choice back. Leave it out to hide the thumbs.
  • Following the stream: the thread ends with an anchor, ChatThreadBlock.latestID, and keeps atLatest true while it is on screen. Wrap your ScrollView in a ScrollViewReader, give it .defaultScrollAnchor(.bottom), and in .onChange(of: messages.last?.text) scroll to latestID only while atLatest is true, so a reader who scrolled up stays where they are. Float ChatThreadBlock.JumpToLatest over the bottom while atLatest is false. The Example does all of this.
  • Copy and Share use the reply as plain text: the markdown marks are removed. Copy in a code block copies just the code.
  • Haptics mark a copy, a rating and a failed reply.
  • VoiceOver reads each turn as "You" or "Assistant" with its text, and announces when a reply finishes, fails or is copied. Text grows with Dynamic Type and code blocks scroll sideways rather than wrap.
  • Reduce Motion turns every change into a short fade.
  • ChatThreadBlockExample at the end of ChatThreadBlock.swift is the conversation from the preview: an answer from earlier today, then a follow-up that streams a template with a code block, over a composer that sends real follow-ups to a stub stream. Pass wrapsInNavigationStack: false to push it in your own stack.

Theming

  • Reads SPTheme from @Environment(\.spTheme) and requires the design-system item.
  • The person's turns are color.fill at radius.md with type.body in textPrimary. Replies are type.body in textPrimary with 3 points of line spacing; bold is set at semibold. Day separators are type.footnote medium in textSecondary.
  • Code blocks are color.fill at 60% with a separator hairline border at radius.md, the language in type.caption and the code in monospaced footnote.
  • Action glyphs are textSecondary at the body size in 44 point targets; a chosen thumb and Copied turn to textPrimary. Stop is a fill capsule. Errors are danger.
  • JumpToLatest is a capsule on surfaceElevated with the e2 elevation and a hairline.
  • Turns appear with motion.smooth and states cross-fade with motion.snappy; both become short fades under Reduce Motion.