Skip to content

feat(web): render agent message bodies as markdown in the Messages tab#300

Open
theref wants to merge 2 commits into
GoogleCloudPlatform:mainfrom
Empiria:feat/messages-markdown-rendering
Open

feat(web): render agent message bodies as markdown in the Messages tab#300
theref wants to merge 2 commits into
GoogleCloudPlatform:mainfrom
Empiria:feat/messages-markdown-rendering

Conversation

@theref
Copy link
Copy Markdown

@theref theref commented Jun 3, 2026

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.

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>
@google-cla
Copy link
Copy Markdown

google-cla Bot commented Jun 3, 2026

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.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 720 to 722
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()
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
    });

Comment on lines 1077 to 1093
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',
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>
meatballs pushed a commit to Empiria/scion that referenced this pull request Jun 3, 2026
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>
meatballs pushed a commit to Empiria/scion that referenced this pull request Jun 3, 2026
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>
meatballs pushed a commit to Empiria/scion that referenced this pull request Jun 3, 2026
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>
meatballs pushed a commit to Empiria/scion that referenced this pull request Jun 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant