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>
|
/gemini review |
|
@theref thanks for the contribution - we do require the CLA - is that something you'd be willing to sign? |
There was a problem hiding this comment.
Code Review
This pull request refactors the agent message viewer to render conversations as a chat with sanitized markdown support, pre-computed date/time fields for better performance, and auto-scroll capabilities. It also extracts markdown rendering into a shared utility. The review feedback highlights three key issues: a potential crash in deriveTimeFields if an invalid timestamp is parsed, a memory leak in queueRender if messages are evicted while rendering asynchronously, and a permanent failure state in the shared markdown renderer if dynamic imports fail.
| function deriveTimeFields(timestamp: string): { | ||
| sortKey: number; | ||
| dateStr: string; | ||
| timeStr: string; | ||
| } { | ||
| const d = new Date(timestamp); | ||
| const ms = d.getTime(); | ||
| return { | ||
| sortKey: Number.isNaN(ms) ? 0 : ms, | ||
| dateStr: d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }), | ||
| timeStr: d.toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit' }), | ||
| }; | ||
| } |
There was a problem hiding this comment.
The deriveTimeFields function parses the message timestamp using new Date(timestamp). If the timestamp is invalid or malformed, d.getTime() returns NaN. Calling toLocaleDateString or toLocaleTimeString on an invalid Date object throws a RangeError: Invalid time value, which will crash the entire message viewer component.
To prevent runtime crashes, check if the parsed time is valid (NaN check) and return fallback strings if it is invalid.
| function deriveTimeFields(timestamp: string): { | |
| sortKey: number; | |
| dateStr: string; | |
| timeStr: string; | |
| } { | |
| const d = new Date(timestamp); | |
| const ms = d.getTime(); | |
| return { | |
| sortKey: Number.isNaN(ms) ? 0 : ms, | |
| dateStr: d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' }), | |
| timeStr: d.toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit' }), | |
| }; | |
| } | |
| function deriveTimeFields(timestamp: string): { | |
| sortKey: number; | |
| dateStr: string; | |
| timeStr: string; | |
| } { | |
| const d = new Date(timestamp); | |
| const ms = d.getTime(); | |
| if (Number.isNaN(ms)) { | |
| return { | |
| sortKey: 0, | |
| dateStr: "Unknown Date", | |
| timeStr: "00:00", | |
| }; | |
| } | |
| return { | |
| sortKey: ms, | |
| dateStr: d.toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" }), | |
| timeStr: d.toLocaleTimeString("en", { hour12: false, hour: "2-digit", minute: "2-digit" }), | |
| }; | |
| } |
| private queueRender(msg: ParsedMessage): void { | ||
| if (!msg.body) return; | ||
| if (this.renderedBodies.has(msg.insertId) || this.pendingRenders.has(msg.insertId)) return; | ||
| this.pendingRenders.add(msg.insertId); | ||
| void renderMarkdown(msg.body) | ||
| .then((rendered) => { | ||
| this.renderedBodies.set(msg.insertId, rendered); | ||
| }) | ||
| .catch(() => { | ||
| // Leave the plain-text fallback in place if rendering fails. | ||
| }) | ||
| .finally(() => { | ||
| this.pendingRenders.delete(msg.insertId); | ||
| this.requestUpdate(); | ||
| }); | ||
| } |
There was a problem hiding this comment.
There is a potential memory leak/stale cache issue in queueRender. If a message is evicted from this.entryMap (via rebuildMessages when the buffer exceeds MAX_BUFFER) while its markdown is still rendering asynchronously, the resolved promise will still execute .then() and add the rendered HTML back into this.renderedBodies. Since the message is no longer in this.entryMap, this rendered HTML will remain in the cache indefinitely.
To fix this, verify that the message is still present in this.entryMap before caching its rendered body.
| private queueRender(msg: ParsedMessage): void { | |
| if (!msg.body) return; | |
| if (this.renderedBodies.has(msg.insertId) || this.pendingRenders.has(msg.insertId)) return; | |
| this.pendingRenders.add(msg.insertId); | |
| void renderMarkdown(msg.body) | |
| .then((rendered) => { | |
| this.renderedBodies.set(msg.insertId, rendered); | |
| }) | |
| .catch(() => { | |
| // Leave the plain-text fallback in place if rendering fails. | |
| }) | |
| .finally(() => { | |
| this.pendingRenders.delete(msg.insertId); | |
| this.requestUpdate(); | |
| }); | |
| } | |
| private queueRender(msg: ParsedMessage): void { | |
| if (!msg.body) return; | |
| if (this.renderedBodies.has(msg.insertId) || this.pendingRenders.has(msg.insertId)) return; | |
| this.pendingRenders.add(msg.insertId); | |
| void renderMarkdown(msg.body) | |
| .then((rendered) => { | |
| if (this.entryMap.has(msg.insertId)) { | |
| this.renderedBodies.set(msg.insertId, rendered); | |
| } | |
| }) | |
| .catch(() => { | |
| // Leave the plain-text fallback in place if rendering fails. | |
| }) | |
| .finally(() => { | |
| this.pendingRenders.delete(msg.insertId); | |
| this.requestUpdate(); | |
| }); | |
| } |
| async function loadRenderer(): Promise<MarkdownRenderer> { | ||
| if (!rendererPromise) { | ||
| rendererPromise = (async () => { | ||
| const [{ marked }, DOMPurify] = await Promise.all([import('marked'), import('dompurify')]); | ||
|
|
||
| const purify = DOMPurify.default ?? DOMPurify; | ||
|
|
||
| return { | ||
| render(markdown: string): string { | ||
| // marked's overload for { async: false } already returns string, so | ||
| // no cast is needed here (verified by tsc + typed lint). | ||
| const rawHtml = marked.parse(markdown, { async: false }); | ||
| return purify.sanitize(rawHtml); | ||
| }, | ||
| }; | ||
| })(); | ||
| } | ||
| return rendererPromise; | ||
| } |
There was a problem hiding this comment.
If the dynamic imports for marked or dompurify fail (for example, due to a transient network issue), the rejected promise is cached in rendererPromise. Subsequent calls to loadRenderer() will immediately return this rejected promise, permanently breaking markdown rendering for the lifetime of the page.
To make the loading robust against transient failures, clear rendererPromise if the initialization fails so that subsequent attempts can retry.
| async function loadRenderer(): Promise<MarkdownRenderer> { | |
| if (!rendererPromise) { | |
| rendererPromise = (async () => { | |
| const [{ marked }, DOMPurify] = await Promise.all([import('marked'), import('dompurify')]); | |
| const purify = DOMPurify.default ?? DOMPurify; | |
| return { | |
| render(markdown: string): string { | |
| // marked's overload for { async: false } already returns string, so | |
| // no cast is needed here (verified by tsc + typed lint). | |
| const rawHtml = marked.parse(markdown, { async: false }); | |
| return purify.sanitize(rawHtml); | |
| }, | |
| }; | |
| })(); | |
| } | |
| return rendererPromise; | |
| } | |
| async function loadRenderer(): Promise<MarkdownRenderer> { | |
| if (!rendererPromise) { | |
| rendererPromise = (async () => { | |
| try { | |
| const [{ marked }, DOMPurify] = await Promise.all([import('marked'), import('dompurify')]); | |
| const purify = DOMPurify.default ?? DOMPurify; | |
| return { | |
| render(markdown: string): string { | |
| const rawHtml = marked.parse(markdown, { async: false }); | |
| return purify.sanitize(rawHtml); | |
| }, | |
| }; | |
| } catch (err) { | |
| rendererPromise = null; | |
| throw err; | |
| } | |
| })(); | |
| } | |
| return rendererPromise; | |
| } |
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.