メッセージ描画コンポーネント
MessageList、StreamingMessageContainer、Messages.ts の 3 コンポーネントが協同して Agent.state.messages を目に見える会話に描画する。MessageList は安定リスト(完了済みメッセージ)、StreamingMessageContainer はストリーミングコンテナ(現在生成中のメッセージ)。この二つを分ける理由は、ストリーミング中にリスト全体が再描画されるのを避けるためだ。Messages.ts は user-message、assistant-message、tool-message、aborted-message などの子要素を定義し、具体的な内容ブロックの描画を処理する。
責務
- 安定リスト:
MessageList.buildRenderItemsがmessagesを走査し、artifactrole を飛ばす。まずrenderMessageでカスタム描画を試し、駄目ならuser-message/assistant-messageにフォールバックする。repeatdirective で key ごとに DOM を再利用する。packages/web-ui/src/components/MessageList.ts:27-81参照。 - ストリーミングコンテナ:
StreamingMessageContainer.setMessageはrequestAnimationFrameで更新をバッチ化し、トークンごとに再描画が走らないようにする。packages/web-ui/src/components/StreamingMessageContainer.ts:28-61参照。 - 深コピーで参照比較を回避:
JSON.parse(JSON.stringify(this._pendingMessage))で描画対象メッセージを深コピーし、Lit がネストしたプロパティ(例えばtoolCall.arguments)の変更を検出できるようにする。packages/web-ui/src/components/StreamingMessageContainer.ts:50-54参照。 - assistant メッセージのブロック別描画:
AssistantMessage.renderはmessage.content配列の順序に従ってtext/thinking/toolCallの 3 種のブロックを描画する。packages/web-ui/src/components/Messages.ts:104-167参照。 - ツール呼び出しのペアリング:
MessageListは先にtoolResultrole をtoolCallIdで Map にまとめる。AssistantMessageはtoolResultsByIdprop で結果を引き、インラインで<tool-message>を描画する。packages/web-ui/src/components/Messages.ts:115-136参照。 - 添付メッセージの変換:
defaultConvertToLlmはuser-with-attachmentsを標準 user message に変換する。画像はImageContent、ドキュメントはTextContentになり、artifact メッセージは LLM に送らないようフィルタされる。packages/web-ui/src/components/Messages.ts:348-383参照。
設計動機
なぜ安定リストとストリーミングコンテナを分けるのか。ストリーミング中は message.content 配列にトークンが逐次追加され、MessageList 全体が requestUpdate で追随すると、更新のたびに buildRenderItems が全履歴を走査し直して、会話が長くなるほど重くなる。分ければ、完了済みメッセージは MessageList に入れて動かず、現在生成中のメッセージだけが StreamingMessageContainer に入る。後者は requestAnimationFrame で更新をバッチ化し、1 フレームにつき最大 1 回しか描画しない。
なぜ JSON.parse(JSON.stringify(...)) のような遅い操作を使うのか。Lit のプロパティ変更検出は参照の浅比較で、Agent はストリーミング中に同じ AssistantMessage オブジェクトの content 配列を直接 mutate する(toolCall.arguments もその場で書き換える)。コピーしないと Lit は参照変化を見逃し、UI が更新されない。深コピーは遅いが、1 フレームにつき 1 メッセージだけ処理するので許容範囲に収まる。
なぜ tool result を単独メッセージとして描画しないのか。LLM が返す assistant メッセージに toolCall があり、直後の toolResult はそのペアだからだ。分けて描画すると文脈が切れる。AssistantMessage は toolResultsById Map で result を toolCall のすぐ横にインライン化し、視覚的に一組に見せる。MessageList が standalone な toolResult role を明示的に skip するのはこのためだ。packages/web-ui/src/components/MessageList.ts:75-79 参照。
主要ファイル
packages/web-ui/src/components/MessageList.ts:11-26—class MessageList宣言と props。packages/web-ui/src/components/MessageList.ts:27-81—buildRenderItems:artifact を skip、renderMessageを呼び、user-message/assistant-messageを組み立て。packages/web-ui/src/components/MessageList.ts:83-92—render:repeatdirective で key ごとに再利用。packages/web-ui/src/components/StreamingMessageContainer.ts:6-26—class StreamingMessageContainer宣言。packages/web-ui/src/components/StreamingMessageContainer.ts:28-61—setMessage:immediate は直送、それ以外はrequestAnimationFrameでバッチ化。packages/web-ui/src/components/Messages.ts:42-82—UserMessageコンポーネント、テキストと添付 tile を描画。packages/web-ui/src/components/Messages.ts:84-168—AssistantMessageコンポーネント、content 配列をブロック別に描画。packages/web-ui/src/components/Messages.ts:226-277—ToolMessageコンポーネント、renderToolを呼びカスタムかデフォルトカードかを決める。packages/web-ui/src/components/Messages.ts:348-383—defaultConvertToLlm:user-with-attachmentsとartifactをフィルタしつつ変換。
setMessage のバッチ化がストリーミング描画の性能の鍵:
// 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 は content 配列の順序で描画し、toolCall は 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>`,
);
}
}データフロー
AgentInterface がイベントをサブスクライブし、2 つのコンポーネントに振り分ける:
境界と失敗
- ストリーミング中の toolCall ペア不全:
toolResultsByIdに対応 id の result が無いことがある。このときresultはundefinedとなり、ToolMessageは pending 扱いになる。packages/web-ui/src/components/Messages.ts:118-122参照。 - aborted メッセージ:
stopReason === "aborted"かつ result 無しのとき、ToolMessageは合成した isError result で穴埋めし、浮いた toolCall が残らないようにする。packages/web-ui/src/components/Messages.ts:248-258参照。 - 重複回避の pending 隠蔽:
hidePendingToolCallsが true のとき、安定リストは結果未着の toolCall をスキップし、StreamingMessageContainerに描画を任せる。packages/web-ui/src/components/Messages.ts:122-124参照。 - artifact メッセージ非表示:
MessageListはartifactrole を明示的に skip する。この種のメッセージは会話復元にだけ使われる。packages/web-ui/src/components/MessageList.ts:39-42参照。 - ストリーミング中の空メッセージ:
StreamingMessageContainerは空でもロード指示の pulse バーを出し、空白の点滅を防ぐ。packages/web-ui/src/components/StreamingMessageContainer.ts:64-70参照。
まとめ
MessageList と StreamingMessageContainer で役割を分ける。安定リストは動かず、ストリーミングコンテナは requestAnimationFrame でバッチ化する。AssistantMessage は content の順にブロック描画し、toolCall は toolResultsById でペアを見てインライン化する。ツール呼び出しの描画詳細は ツールレンダラーレジストリ、イベントの源は AgentInterface セッションホスト を参照。