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

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

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

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

// 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

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →