Skip to main content
Production chat system with 2 UI styles, streaming AI responses, cursor pagination, and comprehensive error handling.
Full technical details in your project at /docs/modules/Feature.Chat.md

What You Get

  • 2 professional UIs - Bubble (WhatsApp) and Centered (ChatGPT)
  • Streaming responses - Real-time token-by-token rendering
  • Cursor pagination - Infinite scroll with smart prefetch
  • Conversation management - Create, rename, delete, filter by title
  • Message limits - Free-tier cap (kFreeMessageLimit = 10) gates non-subscribers
  • Error handling - Retry, cancellation, localized error messages
Time saved: 40-60 hours of UI implementation, streaming logic, pagination, state management, and testing.

Liquid Glass layout

  • ChatView and ChatGPTStyleView host the input bar via .safeAreaInset(edge: .bottom). The scroll view is the full body, not a VStack stacking input below messages. This is what lets Liquid Glass sample the content behind the input bar on iOS 26.
  • The scroll-edge effect is wired through saiScrollEdgeGlass(.bottom) so the bottom of the chat transcript blurs into the input chrome.
  • ChatViewModel has a sibling extension file, ChatViewModel+Memory.swift, which keeps memory-extraction concerns out of the main view model.

UI Styles

WhatsApp/iMessage-like interface:
  • Messages in colored bubbles
  • User on right (blue), AI on left (gray)
  • Timestamps and avatars
  • Smooth animations
Best for: Casual, conversational apps
Style is chosen per conversation - the history screen’s launcher cards (“Bubble Chat” / “Prompt Chat”) pick which UI opens via makeChatView(conversationID:style:).

Key Components

Production Factory

The boilerplate uses a factory pattern in CompositionRoot:
// Factory method with dependency injection
public func makeChatViewModel(conversationID: UUID) -> ChatViewModel {
    ChatViewModel(
        conversationID: conversationID,
        messageRepository: messageRepository,  // SwiftData repository
        llmClient: llmClient,                  // Proxy or Echo client
        paymentsStatusProvider: PaymentsStatusAdapter(paymentsClient: paymentsClient)
    )
}

// Create a dual-style chat container (renders the selected ChatUIStyle)
public func makeDualStyleChatView(
    conversationID: UUID,
    onRequireSubscription: (() -> Void)? = nil
) -> DualStyleChatView {
    let viewModel = makeChatViewModel(conversationID: conversationID)

    return DualStyleChatView(
        viewModel: viewModel,
        onRequireSubscription: onRequireSubscription
    )
}

ChatViewModel

What it handles:
  • Message persistence via messageRepository
  • LLM streaming with cancellation
  • Cursor-based pagination (InfinitePaginator)
  • Error handling and retry (retryLast())
  • Loading states
  • Free-tier message cap via the injected paymentsStatusProvider

ChatHistoryViewModel

Manages conversation list:
@MainActor
@Observable
public final class ChatHistoryViewModel {
    var conversations: [ConversationDTO]
    var isLoading: Bool

    public func loadConversations() async
    public func createNewConversation() async -> ConversationDTO?
    public func renameConversation(id: UUID, newTitle: String) async
    public func deleteConversation(id: UUID) async
}

Streaming Pattern

Real-time AI response rendering:
// In ChatViewModel.send(): accumulate streamed chunks into the message
var accumulatedText = ""
for try await chunk in llmClient.streamResponse(messages: messages) {
    accumulatedText += chunk
    messages[index].text = accumulatedText  // UI updates via @Observable
}
Benefits:
  • ✅ Low latency (first token fast)
  • ✅ Better UX (gradual appearance)
  • ✅ Cancellable generation
  • ✅ Memory efficient

Pagination

Efficient infinite scroll for chat history:
// InfinitePaginator handles:
// - Cursor-based pagination (MessageCursor)
// - Reverse chronological order
// - Prefetch before the user hits the end
// - State tracking (idle / loadingMore / endReached / error)

@MainActor
public final class InfinitePaginator {
    public init(pageSize: Int = 50, prefetchThreshold: Int = 5)

    public private(set) var state: State

    public func shouldLoadMore(visibleIndex: Int, totalCount: Int) -> Bool
    public func loadNext(
        using loader: (MessageCursor?) async throws -> (items: [MessageDTO], next: MessageCursor?)
    ) async -> Result<[ChatMessage], AppError>
    public func reset()
}

Customization Examples

Change Chat Colors

// Bubble colors resolve through DSColors (Packages/DesignSystem):
//   DSColors.bubbleUserOrFallback      -> AccentPrimary colorset
//   DSColors.bubbleAssistantOrFallback -> Surface colorset
//
// Edit those colorsets in
// Packages/DesignSystem/Sources/DesignSystem/Resources/Assets.xcassets
// and both chat styles update automatically.

Add Message Actions

// Add a context menu to MessageRow in ChatView.
// Copy works against the existing ChatMessage.text.
// Regenerate/Delete are methods you add to ChatViewModel yourself —
// they are not shipped on the view model.
.contextMenu {
    Button("Copy", systemImage: "doc.on.doc") {
        UIPasteboard.general.string = message.text
    }
    // Example extension points to implement on ChatViewModel:
    // Button("Regenerate", systemImage: "arrow.clockwise") { ... }
    // Button("Delete", systemImage: "trash", role: .destructive) { ... }
}

Add Custom AI Persona

// In ChatViewModel. LLMMessage.role is a String: "user" | "assistant" | "system".
let systemPrompt = LLMMessage(
    role: "system",
    content: """
    You are a Swift expert.
    Provide clear answers with code examples.
    """
)

let messages = [systemPrompt] + history + [userMessage]
Search ships as client-side filtering in ChatHistoryView (no full-text message search). It matches conversation title and persona name:
// ChatHistoryView.filteredConversations
viewModel.conversations.filter { conversation in
    conversation.title.localizedCaseInsensitiveContains(searchText) ||
    (conversation.personaName?.localizedCaseInsensitiveContains(searchText) ?? false)
}

UI Components

ComponentPackagePurpose
ChatViewFeatureChatBubble-style chat interface
ChatGPTStyleViewFeatureChatCentered-style interface
DualStyleChatViewFeatureChatContainer that renders the selected ChatUIStyle
MessageRowFeatureChatBubble-style message row
ChatHistoryViewFeatureChatConversation list
ChatRowCardFeatureChatConversation row
ChatSearchBarFeatureChatSearch input
EmptyStateViewFeatureChatNo conversations state
SAIInputBarDesignSystemMessage composition bar
ChatBubbleDesignSystemReusable message bubble

Key Files

ComponentLocation
ViewModelsPackages/FeatureChat/Sources/FeatureChat/ViewModels/
ViewsPackages/FeatureChat/Sources/FeatureChat/Views/
ComponentsPackages/FeatureChat/Sources/FeatureChat/Views/Components/
PaginatorPackages/FeatureChat/Sources/FeatureChat/Paginator/

Dependencies

  • Core - Error handling, logging
  • Storage - Conversation and message persistence
  • DesignSystem - UI components and tokens
  • Localization - Localized error messages (AppError.localizedUserMessage)
FeatureChat does not depend on AI. It’s the other way around: LLMClient and LLMMessage live in FeatureChat, and the AI package re-exports FeatureChat (@_exported import FeatureChat). Removing chat without first relocating those types breaks Packages/AI. See the per-module removal notes in docs/checklists/APP_STORE_4_3_HARDENING.md.

Used By

  • AI - Re-exports FeatureChat; ProxyLLMClient (in Packages/AI) conforms to LLMClient. The dev-fallback EchoLLMClient (app target, Composition/LLMClientFactory.swift) also conforms to LLMClient
  • Main App - Primary feature
  • CompositionRoot - Factory methods for ViewModels

Best Practices

  • @MainActor for UI updates
  • @Observable for state
  • Async/await for operations
  • Handle all error cases
  • Update UI incrementally
  • Show typing indicator
  • Support cancellation
  • Handle interruptions
  • Paginate messages
  • Lazy load conversations
  • Compress images
  • Cancel tasks on dismiss

Learn More

Full Documentation

Find complete FeatureChat guide in your project

UI Styles Guide

Find complete Chat UI patterns guide in your project

AI Module

LLM integration

Building Guide

Customize chat

Build with AI (fast)

You can customize this module in minutes using our ready-to-paste LLM prompts.

Example Prompt

Context: Packages/FeatureChat/** Prompt: “Refactor chat UI to centered layout using DS spacing/tokens. Add toggle in Settings. Update tests.”

Example Output

struct TypingIndicatorView: View {
  @State private var isVisible = false
  var body: some View { /* minimal dots animation */ }
}
// TODO: drive from ChatMessage.isStreaming / ChatViewModel.isSending (throttle: 600ms)
See all prompts → /docs/prompts/Feature.Chat.prompts.md
See in project: docs/modules/Feature.Chat.md

Test Coverage

The FeatureChatTests target runs as part of the workspace suite — ~598 tests across 12 package test targets + the app test suites, all in one Boilerplate.xctestplan run. FeatureChat tests cover:
  • Message sending
  • Streaming responses
  • Pagination
  • Conversation management
  • UI rendering
  • Error scenarios