Skip to content

Message rendering components

源码版本v0.73.1

MessageList, StreamingMessageContainer, and Messages.ts together render Agent.state.messages into a visible conversation. MessageList is the stable list (completed messages); StreamingMessageContainer is the streaming container (the message currently being generated). The split exists to avoid re-rendering the entire list during streaming. Messages.ts defines sub-elements like user-message, assistant-message, tool-message, and aborted-message, handling concrete content block rendering.

Responsibilities

  1. Stable list: MessageList.buildRenderItems iterates messages, skipping the artifact role; it first tries renderMessage for custom rendering, then falls back to user-message/assistant-message, using the repeat directive to reuse DOM by key, see packages/web-ui/src/components/MessageList.ts:27-81.
  2. Streaming container: StreamingMessageContainer.setMessage uses requestAnimationFrame to batch merges; during streaming it does not re-render on every token, see packages/web-ui/src/components/StreamingMessageContainer.ts:28-61.
  3. Deep clone to beat reference comparison: JSON.parse(JSON.stringify(this._pendingMessage)) deep-copies the message to render, so Lit picks up on nested property changes (e.g. toolCall.arguments), see packages/web-ui/src/components/StreamingMessageContainer.ts:50-54.
  4. Assistant message block rendering: AssistantMessage.render renders text/thinking/toolCall chunks in the order of the message.content array, see packages/web-ui/src/components/Messages.ts:104-167.
  5. Tool call pairing: MessageList first builds a Map by toolCallId from toolResult roles; AssistantMessage looks up the result through the toolResultsById prop and renders <tool-message> inline, see packages/web-ui/src/components/Messages.ts:115-136.
  6. Attachment message conversion: defaultConvertToLlm turns user-with-attachments into a standard user message — images become ImageContent, documents become TextContent, and artifact messages are filtered out and not sent to the LLM, see packages/web-ui/src/components/Messages.ts:348-383.

Design motivation

Why split into a stable list and a streaming container? Because during streaming the message.content array keeps appending new tokens; if the entire MessageList did requestUpdate along with it, every update would re-run buildRenderItems over the entire history, getting slower the longer the chat. After the split, completed messages go into MessageList and stop changing; only the message currently being generated goes into StreamingMessageContainer, which batches updates with requestAnimationFrame — at most one render per frame.

Why use the slow JSON.parse(JSON.stringify(...))? Because Lit's property change detection is a shallow reference comparison, and Agent directly mutates the content array of the same AssistantMessage object during streaming (toolCall.arguments is also modified in place). Without a clone Lit sees no reference change and the UI does not update. Deep copy is slow, but it only processes one message per frame, so the cost is acceptable.

Why are tool results not rendered as standalone messages? Because the assistant message returned by the LLM contains a toolCall, and the immediately following toolResult is its pair. Rendering them separately would break the context; AssistantMessage uses the toolResultsById Map to inline the result next to the toolCall so they read as one group. That is exactly why MessageList explicitly skips standalone toolResult roles, see packages/web-ui/src/components/MessageList.ts:75-79.

Key files

The batch merging in setMessage is the performance key of streaming rendering:

typescript
// packages/web-ui/src/components/StreamingMessageContainer.ts:44-60
if (!this._updateScheduled) {
    this._updateScheduled = true;

    requestAnimationFrame(async () => {
        if (!this._immediateUpdate && this._pendingMessage !== null) {
            this._message = JSON.parse(JSON.stringify(this._pendingMessage));
            this.requestUpdate();
        }
        this._pendingMessage = null;
        this._updateScheduled = false;
        this._immediateUpdate = false;
    });
}

AssistantMessage.render renders along the content array, and toolCall finds its paired result through toolResultsById:

typescript
// packages/web-ui/src/components/Messages.ts:115-136
} else if (chunk.type === "toolCall") {
    if (!this.hideToolCalls) {
        const tool = this.tools?.find((t) => t.name === chunk.name);
        const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
        const result = this.toolResultsById?.get(chunk.id);
        if (this.hidePendingToolCalls && pending && !result) {
            continue;
        }
        const aborted = this.message.stopReason === "aborted" && !result;
        orderedParts.push(
            html`<tool-message
                .tool=${tool}
                .toolCall=${chunk}
                .result=${result}
                .pending=${pending}
                .aborted=${aborted}
                .isStreaming=${this.isStreaming}
            ></tool-message>`,
        );
    }
}

Data flow

After AgentInterface subscribes to events, it routes messages to the two components:

Edges and failures

Summary

MessageList and StreamingMessageContainer split the work: the stable list stays unchanged, the streaming container batches merges with requestAnimationFrame; AssistantMessage renders chunk-by-chunk along content, and toolCall pairs inline via toolResultsById. For details on tool call rendering see Tool renderer registry; for the source of events see AgentInterface session host.