Message rendering components
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
- Stable list:
MessageList.buildRenderItemsiteratesmessages, skipping theartifactrole; it first triesrenderMessagefor custom rendering, then falls back touser-message/assistant-message, using therepeatdirective to reuse DOM by key, seepackages/web-ui/src/components/MessageList.ts:27-81. - Streaming container:
StreamingMessageContainer.setMessageusesrequestAnimationFrameto batch merges; during streaming it does not re-render on every token, seepackages/web-ui/src/components/StreamingMessageContainer.ts:28-61. - 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), seepackages/web-ui/src/components/StreamingMessageContainer.ts:50-54. - Assistant message block rendering:
AssistantMessage.renderrenderstext/thinking/toolCallchunks in the order of themessage.contentarray, seepackages/web-ui/src/components/Messages.ts:104-167. - Tool call pairing:
MessageListfirst builds a Map bytoolCallIdfromtoolResultroles;AssistantMessagelooks up the result through thetoolResultsByIdprop and renders<tool-message>inline, seepackages/web-ui/src/components/Messages.ts:115-136. - Attachment message conversion:
defaultConvertToLlmturnsuser-with-attachmentsinto a standard user message — images becomeImageContent, documents becomeTextContent, and artifact messages are filtered out and not sent to the LLM, seepackages/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
packages/web-ui/src/components/MessageList.ts:11-26—class MessageListdeclaration and props.packages/web-ui/src/components/MessageList.ts:27-81—buildRenderItems: skip artifacts, callrenderMessage, assembleuser-message/assistant-message.packages/web-ui/src/components/MessageList.ts:83-92—render: use therepeatdirective to reuse by key.packages/web-ui/src/components/StreamingMessageContainer.ts:6-26—class StreamingMessageContainerdeclaration.packages/web-ui/src/components/StreamingMessageContainer.ts:28-61—setMessage: immediate sends go direct, non-immediate ones batch throughrequestAnimationFrame.packages/web-ui/src/components/Messages.ts:42-82—UserMessagecomponent, renders text and attachment tiles.packages/web-ui/src/components/Messages.ts:84-168—AssistantMessagecomponent, renders by chunks along the content array.packages/web-ui/src/components/Messages.ts:226-277—ToolMessagecomponent, callsrenderToolto decide between custom and default card.packages/web-ui/src/components/Messages.ts:348-383—defaultConvertToLlm: filter and convertuser-with-attachmentsandartifact.
The batch merging in setMessage is the performance key of streaming rendering:
// 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:
// 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
- Incomplete toolCall pairing during streaming:
toolResultsByIdmay not have a result for the corresponding id;resultis thenundefined, andToolMessageis marked as pending, seepackages/web-ui/src/components/Messages.ts:118-122. - Aborted messages: when
stopReason === "aborted"and there is no result,ToolMessagesynthesizes an isError result as a placeholder to avoid a dangling toolCall, seepackages/web-ui/src/components/Messages.ts:248-258. - Hide pending to avoid dupes: when
hidePendingToolCallsis true, the stable list skips toolCalls that do not yet have results, leaving them forStreamingMessageContainerto render, seepackages/web-ui/src/components/Messages.ts:122-124. - Artifact messages not shown:
MessageListexplicitly skips theartifactrole; these messages are only used for session rebuild, seepackages/web-ui/src/components/MessageList.ts:39-42. - Empty message during streaming:
StreamingMessageContainerstill shows a loading pulse bar to avoid a blank flash, seepackages/web-ui/src/components/StreamingMessageContainer.ts:64-70.
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.