Pattern · AI Chat
NewChat 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
$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 aLazyVStackfor yourScrollView.ChatThreadBlock.Messagehas anid, arole(.user,.assistant),text(markdown for replies), adate, astate(.complete,.streaming,.stopped,.failed(String)) and an optionalfeedback(.good,.bad).Message.sampleis the question and answer from the preview.replyreceives every message before the reply and returns anAsyncThrowingStream<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'serrorDescription, or "The reply didn't finish. Try again." Your binding always holds the thread as shown, so save it from there.onFeedbackruns 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 keepsatLatesttrue while it is on screen. Wrap yourScrollViewin aScrollViewReader, give it.defaultScrollAnchor(.bottom), and in.onChange(of: messages.last?.text)scroll tolatestIDonly whileatLatestis true, so a reader who scrolled up stays where they are. FloatChatThreadBlock.JumpToLatestover the bottom whileatLatestis 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.
ChatThreadBlockExampleat the end ofChatThreadBlock.swiftis 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. PasswrapsInNavigationStack: falseto push it in your own stack.
Theming
- Reads
SPThemefrom@Environment(\.spTheme)and requires thedesign-systemitem. - The person's turns are
color.fillatradius.mdwithtype.bodyintextPrimary. Replies aretype.bodyintextPrimarywith 3 points of line spacing; bold is set at semibold. Day separators aretype.footnotemedium intextSecondary. - Code blocks are
color.fillat 60% with aseparatorhairline border atradius.md, the language intype.captionand the code in monospaced footnote. - Action glyphs are
textSecondaryat the body size in 44 point targets; a chosen thumb and Copied turn totextPrimary. Stop is afillcapsule. Errors aredanger. JumpToLatestis a capsule onsurfaceElevatedwith the e2 elevation and a hairline.- Turns appear with
motion.smoothand states cross-fade withmotion.snappy; both become short fades under Reduce Motion.
03
Keep building
Pieces that work well next to this one.