# ChatMCP Chat Message Rendering Pipeline: How Different Message Types Are Displayed

> Explore the ChatMCP chat message rendering pipeline. Learn how ChatMCP transforms LLM data into interactive UI bubbles, displaying various message types like user, assistant, and tool messages effectively.

- Repository: [刀刀/chatmcp](https://github.com/daodao97/chatmcp)
- Tags: internals
- Published: 2026-02-28

---

**ChatMCP implements a four-stage pipeline that transforms raw LLM data into interactive UI bubbles, using `ChatMessage` models, SQLite persistence, tree-based message grouping, and role-specific widgets to display user, assistant, tool, loading, and error messages differently.**

ChatMCP is an open-source Flutter-based MCP client that manages complex LLM conversations with support for branching, file attachments, and tool calls. Understanding its chat message rendering pipeline is essential for developers who want to customize how different message types appear or debug conversation flow issues. This article breaks down the complete journey from database storage to pixel rendering according to the `daodao97/chatmcp` source code.

## The Four-Stage Rendering Architecture

The pipeline moves from raw data to visible widgets through distinct layers: data modeling, persistence, provider preparation, and UI rendering.

### Stage 1: Data Modeling with ChatMessage

Every message begins as a `ChatMessage` object defined in `lib/llm/model.dart` (lines 37-54). This immutable model stores the `MessageRole` enum value (`system`, `user`, `assistant`, `tool`, `loading`, `error`), the text content, optional file attachments, token usage statistics, and metadata like `toolCallId` for function results.

The model supports serialization to and from JSON, enabling seamless conversion between the Dart objects and the SQLite database representation.

### Stage 2: Persistence and Repository Layer

Messages persist in a SQLite table named `chat_message` via the `DbChatMessage` class in `lib/dao/chat_message.dart` (lines 4-24). The `LocalChatRepository` handles the bridge between the database and the application layer.

When loading history, `LocalChatRepository.getChatMessages` (lines 90-94) queries the database, converts each `DbChatMessage` back into a `ChatMessage`, and returns the list ordered by creation time. Notably, the repository filters out error messages during persistence: `addChatMessage` explicitly skips any message where `role == MessageRole.error` (lines 79-81), preventing errors from cluttering the conversation history.

### Stage 3: Provider and UI Preparation

The `ChatProvider` (defined in `lib/provider/chat_provider.dart`) holds the currently active `Chat` and the in-memory list of `ChatMessage` objects. When a user opens a chat, the UI layer calls `_getHistoryTreeMessages()` inside `ChatPage` (`lib/page/layout/chat_page/chat_page.dart`, lines 93-128).

This method builds a parent-child map that enables branching conversations—essential for handling tool calls, retries, and parallel message versions. It populates helper fields `childMessageIds` and `brotherMessageIds` on each `ChatMessage`, creating a tree structure rather than a flat list. System messages are filtered out during this tree-building phase, ensuring they remain available as LLM context but never appear in the UI widget tree.

### Stage 4: Widget Rendering Based on MessageRole

The final rendering logic lives in `lib/page/layout/chat_page/chat_message.dart`. The `ChatMessageContent` widget receives the processed message tree and dispatches to specific builders based on the `role` property:

- **User messages**: Right-aligned bubbles using `MessageBubble` with `isUser = true`, rendered with the `Markit` markdown widget
- **Assistant messages**: Left-aligned bubbles with optional token-usage accordion panels
- **Tool results**: Displayed like assistant bubbles but only when `toolCallId` is non-null (lines 74-82)
- **Loading states**: Replaced entirely by the `ChatLoading` spinner widget (lines 35-37)

## How Each MessageRole Is Displayed

The UI treats each role distinctly to create the familiar chat interface:

- **MessageRole.user**: Rendered on the right side via `ChatMessageContent._buildMessage` (lines 52-60). Content passes through `Markit` for markdown rendering. If the message contains a non-empty `files` list, a `FileAttachment` widget prepends the text bubble.

- **MessageRole.assistant**: Rendered on the left side (lines 70-84). When `tokenUsage` data is present, the widget wraps the content in a `CollapsibleSection` to show an expandable "Token Usage" panel.

- **MessageRole.tool**: Shown as a left-aligned bubble only when `toolCallId` is non-null. This displays the JSON or text results from MCP tool calls, using the same bubble styling as assistant messages but with distinct internal formatting.

- **MessageRole.loading**: Instead of a bubble, the UI renders the `ChatLoading` spinner widget to indicate active generation.

- **MessageRole.system**: Never added to the UI list. These messages exist in the database for LLM context but `ChatPage._initializeHistoryMessages` ignores them when building the visual tree.

- **MessageRole.error**: Explicitly filtered at the repository layer. Because `LocalChatRepository.addChatMessage` skips these during persistence, they never reach the UI rendering phase.

## Handling Message Groups and Branching

Consecutive messages from the same sender merge into visual groups using `ChatUIMessage`. The `_filterMessages` method removes empty assistant bubbles, while `_buildMessageGroup` calculates bubble positions (`first`, `middle`, `last`, `single`) to create the "speech bubble" aesthetic with appropriate corner rounding.

For branching conversations, the tree-building logic assigns `childMessageIds` and `brotherMessageIds` to each message. While `ChatMessageContent` handles individual bubble rendering, `ChatUIMessage` manages the group container that enables branch switching through the "Switch" button in `ChatMessageAction`, allowing users to navigate between parallel conversation paths.

## Practical Code Examples

### Creating a Tool Result Message

```dart
import 'package:chatmcp/llm/model.dart';
import 'package:chatmcp/provider/provider_manager.dart';

final toolResult = ChatMessage(
  role: MessageRole.tool,
  content: 'Current temperature: 72°F, Sunny',
  toolCallId: 'weather-lookup-123',
);

await ProviderManager.chatProvider.addChatMessage(chatId, [toolResult]);

```

*Source:* `ChatMessage` constructor in `lib/llm/model.dart` (lines 37-48).

### Attaching Files to User Messages

```dart
final screenshot = File(
  name: 'error_screenshot.png',
  path: '/tmp/error.png',
  size: 124532,
  fileType: 'image/png',
);

final userMsg = ChatMessage(
  role: MessageRole.user,
  content: 'Please analyze this error image',
  files: [screenshot],
);

await ProviderManager.chatProvider.addChatMessage(chatId, [userMsg]);

```

*Source:* `File` model definition in `lib/llm/model.dart` (lines 19-27).

### Rendering the Message List

```dart
// Inside ChatPage.build()
return ListView.builder(
  itemCount: _messages.length,
  itemBuilder: (context, index) => ChatUIMessage(
    messages: _messages[index],
    onRetry: _retryMessage,
    onSwitch: _switchBranch,
  ),
);

```

*Source:* `ChatUIMessage` widget implementation in `lib/page/layout/chat_page/chat_message.dart` (lines 22-31).

### Customizing Bubble Appearance

```dart
class MessageBubble extends StatelessWidget {
  final ChatMessage message;
  
  @override
  Widget build(BuildContext context) {
    final isUser = message.role == MessageRole.user;
    final backgroundColor = AppColors.getMessageBubbleBackgroundColor(
      context, 
      isUser,
    );
    
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        color: backgroundColor,
        child: Markit(data: message.content),
      ),
    );
  }
}

```

*Source:* `MessageBubble.build` in `lib/page/layout/chat_page/chat_message.dart` (lines 90-98).

## Summary

- ChatMCP uses a **four-stage pipeline**: data modeling (`ChatMessage`), SQLite persistence (`LocalChatRepository`), tree preparation (`ChatPage._getHistoryTreeMessages`), and role-based widget rendering (`ChatMessageContent`).

- **Six message roles** exist (`user`, `assistant`, `tool`, `loading`, `system`, `error`), but only the first four render visible UI elements; system and error messages are filtered out during tree building and persistence, respectively.

- **Tool results** display only when `toolCallId` is present, while **loading states** replace bubbles entirely with a spinner widget.

- The repository supports **branching conversations** through parent-child relationships calculated in the provider layer, enabling retry and switch functionality in the UI.

- **File attachments** render as separate widgets prepended to user message bubbles when the `files` list is non-empty.

## Frequently Asked Questions

### How does ChatMCP distinguish between regular assistant messages and tool call results?

Both use left-aligned bubbles, but tool messages require a non-null `toolCallId` field. In `ChatMessageContent._buildMessage` (lines 74-82), the widget checks for `role == MessageRole.tool && toolCallId != null` to determine whether to render the content as a tool result versus a standard assistant reply.

### Why don't system messages appear in the chat interface?

System messages are intentionally filtered during the UI preparation phase. While they persist in the SQLite database for LLM context, `ChatPage._initializeHistoryMessages` excludes them when building the message tree that feeds the ListView, ensuring only user-visible roles reach the widget layer.

### How does message branching work in the rendering pipeline?

During Stage 3, `_getHistoryTreeMessages()` in `ChatPage` constructs a parent-child map that assigns `childMessageIds` and `brotherMessageIds` to each `ChatMessage`. The `ChatUIMessage` widget uses these relationships to group related messages and provide "Switch" buttons that allow users to navigate between parallel conversation branches or retry variations.

### Where is the markdown content actually rendered?

The `Markit` widget (located in `lib/widgets/markdown/markit_widget.dart`) handles all markdown parsing and rendering for user and assistant message content. `ChatMessageContent` passes the raw message text to `Markit` inside `MessageBubble`, ensuring consistent formatting for code blocks, lists, and inline styling across all visible message types.