feat(web): render agent message bodies as markdown in the Messages tab#300
feat(web): render agent message bodies as markdown in the Messages tab#300theref wants to merge 2 commits into
Conversation
The per-agent Messages tab rendered message bodies as plain text with a 300-char truncation and a click-to-JSON log-viewer interaction. Agent harnesses emit markdown-formatted prose (headings, tables, lists, code blocks, blockquotes), so replies were effectively unreadable. Rework the viewer into a conversation view: - Message bodies render as sanitized markdown (marked + DOMPurify, both already dependencies), lazy-loaded via a new shared shared/markdown.ts renderer that scion-markdown-preview now shares too. - Oldest-first ordering with the latest at the bottom, a sticky composer, and a jump-to-latest control when scrolled up. - Operator instructions and agent replies are visually distinguished; the raw JSON envelope is demoted to a per-message raw (</>) toggle. - Conversational message types (assistant-reply, instruction, …) render without noisy type badges. Rendering is harness-agnostic: it formats whatever text the message store holds, independent of which harness produced it. Frontend-only — no API, schema, or backend changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request refactors markdown rendering into a shared helper and updates the agent message viewer to render conversation logs as a chat interface with sanitized markdown support, auto-scrolling, and a 'jump to latest' button. The review feedback identifies critical performance bottlenecks in the message viewer, specifically regarding inefficient sorting of buffered messages via repeated Date parsing and typing lag caused by formatting dates on every render. Additionally, a TypeScript casting adjustment is recommended in the shared markdown helper to prevent compilation errors under strict configurations.
| const sorted = Array.from(this.entryMap.values()).sort( | ||
| (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() | ||
| (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() | ||
| ); |
There was a problem hiding this comment.
Sorting the buffered messages by parsing the timestamp string into a Date object on every comparison is highly inefficient, especially during streaming where rebuildMessages is called for every incoming message. Since the timestamps are ISO 8601 strings, we can compare them lexicographically directly using standard string comparison operators, which avoids object allocation and parsing overhead.
const sorted = Array.from(this.entryMap.values()).sort((a, b) => {
if (a.timestamp < b.timestamp) return -1;
if (a.timestamp > b.timestamp) return 1;
return 0;
});| const d = new Date(msg.timestamp); | ||
| const dateStr = d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }); | ||
| const dateStr = d.toLocaleDateString('en', { | ||
| year: 'numeric', | ||
| month: 'short', | ||
| day: 'numeric', | ||
| }); | ||
|
|
||
| if (dateStr !== lastDate) { | ||
| lastDate = dateStr; | ||
| rows.push(html`<div class="date-divider">${dateStr}</div>`); | ||
| } | ||
|
|
||
| const timeStr = d.toLocaleTimeString('en', { | ||
| hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', | ||
| hour12: false, | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| }); |
There was a problem hiding this comment.
Instantiating a Date object and calling toLocaleDateString and toLocaleTimeString for every message on every render is a major performance bottleneck. Since composeText is a @state() property, typing in the compose box triggers a full re-render of the component on every keystroke. With hundreds of messages, this causes severe typing lag.
To fix this, pre-calculate and cache dateStr and timeStr on the ParsedMessage object when parsing the messages (in parseHubMessage and parseEntry), and then simply reference them here.
const dateStr = msg.dateStr;
if (dateStr !== lastDate) {
lastDate = dateStr;
rows.push(html`<div class="date-divider">${dateStr}</div>`);
}
const timeStr = msg.timeStr;|
|
||
| return { | ||
| render(markdown: string): string { | ||
| const rawHtml = marked.parse(markdown, { async: false }); |
There was a problem hiding this comment.
In strict TypeScript configurations, marked.parse is typed to return string | Promise<string>. Passing this union type directly to purify.sanitize (which expects a string) will cause a compilation error. Since we are passing { async: false }, we know the result is synchronous and can safely cast it as string to ensure type safety and successful compilation.
| const rawHtml = marked.parse(markdown, { async: false }); | |
| const rawHtml = marked.parse(markdown, { async: false }) as string; |
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on GoogleCloudPlatform#300. - rebuildMessages sorted by re-parsing each timestamp into a Date on every comparison; it runs per streamed message, so that is hot. Derive an epoch-ms sortKey once at parse time and sort numerically. (Numeric key rather than a lexicographic string compare so ordering stays correct across the hub-store and Cloud-Logging sources even if their RFC3339 offsets ever differ.) - renderMessages created a Date and ran Intl date/time formatting for every message on every render; because composeText is @State, each keystroke re-rendered the whole list and re-formatted every row. Pre-format dateStr/timeStr once at parse time and just reference them in the loop. deriveTimeFields centralises this parse-time derivation for both the hub and Cloud-Logging parse paths. No cast is added in shared/markdown.ts: marked's overload for { async: false } already returns string (confirmed by tsc and typed lint), so `as string` would be flagged as an unnecessary assertion here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-agent Messages tab rendered message bodies as plain text with a 300-char truncation and a click-to-JSON log-viewer interaction. Agent harnesses emit markdown-formatted prose (headings, tables, lists, code blocks, blockquotes), so replies were effectively unreadable.
Rework the viewer into a conversation view:
Rendering is harness-agnostic: it formats whatever text the message store holds, independent of which harness produced it. Frontend-only — no API, schema, or backend changes.