From eafc64347876d982af678d08534fc964e0d3de6e Mon Sep 17 00:00:00 2001 From: James Campbell Date: Wed, 3 Jun 2026 08:34:36 +0100 Subject: [PATCH 1/2] feat(web): render agent message bodies as markdown in the Messages tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/shared/agent-message-viewer.ts | 646 ++++++++++++------ web/src/components/shared/markdown-preview.ts | 55 +- web/src/shared/markdown.ts | 59 ++ 3 files changed, 520 insertions(+), 240 deletions(-) create mode 100644 web/src/shared/markdown.ts diff --git a/web/src/components/shared/agent-message-viewer.ts b/web/src/components/shared/agent-message-viewer.ts index 91ec45f09..43890985a 100644 --- a/web/src/components/shared/agent-message-viewer.ts +++ b/web/src/components/shared/agent-message-viewer.ts @@ -17,14 +17,23 @@ /** * Agent message viewer component. * - * Displays structured messages from the dedicated "scion-messages" Cloud - * Logging log. Shows message direction (sent/received), sender/recipient, - * and provides a compose box for sending new messages with optional interrupt. + * Renders the conversation between an operator and an agent as a chat: the + * operator's instructions and the agent's replies, oldest-first with the latest + * at the bottom. Message bodies are rendered as sanitized markdown (agent + * harnesses emit markdown-formatted prose), so headings, tables, lists and code + * blocks display properly rather than as raw text. The rendering is + * harness-agnostic — it formats whatever text the message store holds, + * independent of which harness produced it. + * + * Messages come from the Hub message store (primary) with a Cloud Logging + * fallback. A compose box supports sending new messages with optional interrupt. */ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { apiFetch, extractApiError } from '../../client/api.js'; +import { renderMarkdown } from '../../shared/markdown.js'; import type { Message } from '../../shared/types.js'; import './json-browser.js'; @@ -61,6 +70,10 @@ interface ParsedMessage { const MAX_BUFFER = 500; +/** Message types that are part of normal conversational flow; their type + * badge is suppressed to keep the chat clean. */ +const COMMON_TYPES = new Set(['', 'instruction', 'assistant-reply', 'message', 'chat', 'reply']); + @customElement('scion-agent-message-viewer') export class ScionAgentMessageViewer extends LitElement { @property() @@ -120,6 +133,12 @@ export class ScionAgentMessageViewer extends LitElement { @state() private loaded = false; @state() private expandedIds = new Set(); + /** Cache of insertId -> sanitized rendered-markdown HTML for message bodies. */ + @state() private renderedBodies = new Map(); + /** Whether the conversation is scrolled to the latest message; controls auto-scroll. */ + @state() private pinned = true; + private pendingRenders = new Set(); + // Compose state @state() private composeText = ''; @state() private composeInterrupt = false; @@ -134,56 +153,9 @@ export class ScionAgentMessageViewer extends LitElement { display: block; } - /* Compose box */ - .compose-box { + .viewer { display: flex; flex-direction: column; - gap: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - background: var(--scion-bg-subtle, #f1f5f9); - border: 1px solid var(--scion-border, #e2e8f0); - border-radius: var(--scion-radius, 0.5rem); - } - .compose-label { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--scion-text-muted, #64748b); - } - .compose-row { - display: flex; - align-items: flex-start; - gap: 0.75rem; - } - .compose-input { - flex: 1; - } - .compose-input sl-input::part(base) { - font-size: 0.875rem; - } - .compose-actions { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; - padding-top: 0.125rem; - } - .compose-actions label { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.8125rem; - color: var(--scion-text-muted, #64748b); - cursor: pointer; - white-space: nowrap; - } - .send-error { - font-size: 0.75rem; - color: var(--scion-danger-600, #dc2626); - margin-top: 0.375rem; } /* Toolbar */ @@ -192,14 +164,13 @@ export class ScionAgentMessageViewer extends LitElement { align-items: center; justify-content: flex-end; gap: 0.75rem; - margin-bottom: 1rem; + margin-bottom: 0.5rem; } .toolbar-label { font-size: 0.8125rem; color: var(--scion-text-muted, #64748b); margin-right: 0.25rem; } - .stream-indicator { display: inline-flex; align-items: center; @@ -215,91 +186,119 @@ export class ScionAgentMessageViewer extends LitElement { animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } - /* Message list */ - .message-list { + /* Conversation scroll region */ + .conversation-wrap { + position: relative; + } + .conversation { display: flex; flex-direction: column; - gap: 0; + gap: 1.5rem; + max-height: calc(100vh - 22rem); + min-height: 16rem; + overflow-y: auto; + padding: 1rem 0.25rem; } - .message-row { - display: flex; - align-items: flex-start; - gap: 0.75rem; - padding: 0.625rem 0.75rem; - border-bottom: 1px solid var(--scion-border, #e2e8f0); + /* Jump-to-latest */ + .jump-latest { + position: absolute; + left: 50%; + bottom: 0.75rem; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--scion-text, #0f172a); + background: var(--scion-surface, #ffffff); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: 999px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); cursor: pointer; - transition: background 0.1s ease; } - .message-row:hover { + .jump-latest:hover { background: var(--scion-bg-subtle, #f1f5f9); } - .msg-direction { - display: flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - border-radius: 50%; - flex-shrink: 0; - margin-top: 0.125rem; - } - .msg-direction.received { - background: var(--scion-primary-50, #eff6ff); - color: var(--scion-primary-600, #2563eb); + /* Date divider */ + .date-divider { + align-self: center; + padding: 0.25rem 0.75rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--scion-text-muted, #64748b); + text-transform: uppercase; + letter-spacing: 0.05em; } - .msg-direction.sent { - background: var(--scion-success-50, #f0fdf4); - color: var(--scion-success-600, #16a34a); + + /* Turn (one message) */ + .turn { + display: flex; + flex-direction: column; + gap: 0.375rem; + max-width: 100%; } - .msg-direction sl-icon { - font-size: 0.875rem; + .turn.user { + align-self: flex-end; + align-items: flex-end; + max-width: 80%; } - - .msg-content { - flex: 1; - min-width: 0; + .turn.agent { + align-self: stretch; } - .msg-header { + .turn-caption { display: flex; align-items: center; gap: 0.5rem; - margin-bottom: 0.25rem; - flex-wrap: wrap; - } - .msg-actor { - font-size: 0.875rem; - font-weight: 600; - color: var(--scion-text, #0f172a); - } - .msg-arrow { - font-size: 0.8125rem; - font-weight: 600; - color: var(--scion-text, #0f172a); + font-size: 0.75rem; + color: var(--scion-text-muted, #64748b); } - .msg-target { - font-size: 0.875rem; + .turn-actor { font-weight: 600; color: var(--scion-text, #0f172a); } - .msg-time { + .turn-arrow { font-size: 0.6875rem; + } + .turn-time { color: var(--scion-text-muted, #64748b); - margin-left: auto; - white-space: nowrap; } - - .msg-badges { - display: flex; - gap: 0.375rem; + .raw-toggle { + display: inline-flex; align-items: center; + padding: 0.125rem; + border: none; + background: none; + color: var(--scion-text-muted, #94a3b8); + cursor: pointer; + border-radius: 0.25rem; + font-size: 0.75rem; + opacity: 0; + transition: + opacity 0.1s ease, + color 0.1s ease; + } + .turn:hover .raw-toggle { + opacity: 1; } + .raw-toggle:hover { + color: var(--scion-text, #0f172a); + background: var(--scion-bg-subtle, #f1f5f9); + } + .msg-badge { display: inline-block; padding: 0.0625rem 0.375rem; @@ -322,32 +321,192 @@ export class ScionAgentMessageViewer extends LitElement { color: var(--scion-warning-700, #b45309); } - .msg-body { - font-size: 0.8125rem; - color: var(--scion-text-secondary, #475569); - line-height: 1.5; + /* Message body */ + .md-body { + font-size: 0.9375rem; + line-height: 1.7; + color: var(--scion-text, #1e293b); word-break: break-word; + } + .md-body.plain { white-space: pre-wrap; } + /* User turns sit in a contained bubble; agent turns render full-width. */ + .turn.user .md-body { + padding: 0.5rem 0.875rem; + background: var(--scion-bg-subtle, #f1f5f9); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + } - /* Expanded detail */ + /* Markdown content styles (scoped to rendered bodies) */ + .md-body :first-child { + margin-top: 0; + } + .md-body :last-child { + margin-bottom: 0; + } + .md-body h1, + .md-body h2, + .md-body h3, + .md-body h4, + .md-body h5, + .md-body h6 { + margin: 1.2em 0 0.5em; + font-weight: 600; + line-height: 1.3; + color: var(--scion-text, #1e293b); + } + .md-body h1 { + font-size: 1.375rem; + } + .md-body h2 { + font-size: 1.1875rem; + } + .md-body h3 { + font-size: 1.0625rem; + } + .md-body h4 { + font-size: 1rem; + } + .md-body p { + margin: 0 0 0.75em; + } + .md-body a { + color: var(--sl-color-primary-600, #2563eb); + text-decoration: none; + } + .md-body a:hover { + text-decoration: underline; + } + .md-body code { + font-family: var(--scion-font-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 0.85em; + background: var(--scion-bg-subtle, #f8fafc); + padding: 0.15em 0.35em; + border-radius: 0.25rem; + border: 1px solid var(--scion-border, #e2e8f0); + } + .md-body pre { + background: var(--scion-bg-subtle, #f8fafc); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + padding: 0.875rem 1rem; + overflow-x: auto; + margin: 0 0 0.75em; + } + .md-body pre code { + background: none; + border: none; + padding: 0; + font-size: 0.8125rem; + } + .md-body blockquote { + border-left: 3px solid var(--sl-color-primary-200, #bfdbfe); + margin: 0 0 0.75em; + padding: 0.25em 1em; + color: var(--scion-text-muted, #64748b); + } + .md-body blockquote p:last-child { + margin-bottom: 0; + } + .md-body ul, + .md-body ol { + margin: 0 0 0.75em; + padding-left: 1.5em; + } + .md-body li { + margin-bottom: 0.2em; + } + .md-body table { + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + margin: 0 0 0.75em; + font-size: 0.875rem; + } + .md-body th, + .md-body td { + border: 1px solid var(--scion-border, #e2e8f0); + padding: 0.4em 0.65em; + text-align: left; + } + .md-body th { + background: var(--scion-bg-subtle, #f8fafc); + font-weight: 600; + } + .md-body hr { + border: none; + border-top: 1px solid var(--scion-border, #e2e8f0); + margin: 1.2em 0; + } + .md-body img { + max-width: 100%; + height: auto; + border-radius: var(--scion-radius, 0.5rem); + } + + /* Raw (expanded) detail */ .msg-detail { - margin-top: 0.5rem; + margin-top: 0.25rem; padding: 0.5rem 0.75rem; background: var(--scion-bg-subtle, #f1f5f9); border-radius: var(--scion-radius, 0.5rem); + width: 100%; } - /* Date divider */ - .date-divider { - padding: 0.5rem 0.75rem 0.25rem; - font-size: 0.6875rem; + /* Compose box (bottom) */ + .compose-box { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + margin-top: 1rem; + background: var(--scion-bg-subtle, #f1f5f9); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + } + .compose-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; font-weight: 600; color: var(--scion-text-muted, #64748b); - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid var(--scion-border, #e2e8f0); - background: var(--scion-surface, #ffffff); + } + .compose-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + } + .compose-input { + flex: 1; + } + .compose-input sl-input::part(base) { + font-size: 0.875rem; + } + .compose-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + padding-top: 0.125rem; + } + .compose-actions label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--scion-text-muted, #64748b); + cursor: pointer; + white-space: nowrap; + } + .send-error { + font-size: 0.75rem; + color: var(--scion-danger-600, #dc2626); + margin-top: 0.375rem; } /* Empty / Loading / Error */ @@ -374,6 +533,15 @@ export class ScionAgentMessageViewer extends LitElement { this.stopStream(); } + override updated(): void { + // Keep the latest message in view while the user is pinned to the bottom. + // Re-runs as messages stream in and as async markdown render changes height. + if (this.pinned) { + const el = this.conversationEl; + if (el) el.scrollTop = el.scrollHeight; + } + } + /** Called by the parent when the messages tab is first shown. */ loadMessages(): void { if (this.loaded) return; @@ -429,13 +597,19 @@ export class ScionAgentMessageViewer extends LitElement { const params = new URLSearchParams({ tail: '200' }); if (this.messages.length > 0) { - params.set('since', this.messages[0].timestamp); + // Messages are ordered oldest-first, so the newest is last. + params.set('since', this.messages[this.messages.length - 1].timestamp); } const res = await apiFetch(`${baseUrl}?${params.toString()}`); if (!res.ok) { - const errData = await res.json().catch(() => ({})) as { error?: { message?: string }; message?: string }; + const errData = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + message?: string; + }; throw new Error( - (errData.error as { message?: string })?.message || errData.message || `HTTP ${res.status}` + (errData.error as { message?: string })?.message || + errData.message || + `HTTP ${res.status}` ); } const logData = (await res.json()) as MessageLogsResponse; @@ -451,9 +625,8 @@ export class ScionAgentMessageViewer extends LitElement { private parseHubMessage(msg: Message): ParsedMessage { // Hub store: senderId === agentId means the agent sent the message (outbound). // Otherwise the agent is the recipient (inbound from human). - const direction: 'sent' | 'received' = !this.agentId || msg.senderId === this.agentId - ? 'sent' - : 'received'; + const direction: 'sent' | 'received' = + !this.agentId || msg.senderId === this.agentId ? 'sent' : 'received'; return { sender: msg.sender, @@ -474,21 +647,10 @@ export class ScionAgentMessageViewer extends LitElement { const parsed = this.parseHubMessage(item); if (!this.entryMap.has(parsed.insertId)) { this.entryMap.set(parsed.insertId, parsed); + this.queueRender(parsed); } } - - const sorted = Array.from(this.entryMap.values()).sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ); - - if (sorted.length > MAX_BUFFER) { - const evicted = sorted.splice(MAX_BUFFER); - for (const e of evicted) { - this.entryMap.delete(e.insertId); - } - } - - this.messages = sorted; + this.rebuildMessages(); } private parseEntry(entry: MessageLogEntry): ParsedMessage { @@ -497,8 +659,8 @@ export class ScionAgentMessageViewer extends LitElement { const sender = labels['sender'] || (payload['sender'] as string) || ''; const recipient = labels['recipient'] || (payload['recipient'] as string) || ''; const msgType = labels['msg_type'] || (payload['msg_type'] as string) || ''; - const urgent = (payload['urgent'] === true) || (labels['urgent'] === 'true'); - const broadcasted = (payload['broadcasted'] === true) || (labels['broadcasted'] === 'true'); + const urgent = payload['urgent'] === true || labels['urgent'] === 'true'; + const broadcasted = payload['broadcasted'] === true || labels['broadcasted'] === 'true'; // Determine direction relative to this agent using unique IDs. // Check sender_id and recipient_id labels first (UUID-based, unambiguous). @@ -526,8 +688,7 @@ export class ScionAgentMessageViewer extends LitElement { // payload['message'] and entry.message are the Cloud Logging message // (e.g. "message dispatched"), NOT the scion message content. // The actual message body is in payload['message_content']. - const body = (payload['message_content'] as string) - || ''; + const body = (payload['message_content'] as string) || ''; return { sender, @@ -546,24 +707,50 @@ export class ScionAgentMessageViewer extends LitElement { private mergeEntries(newEntries: MessageLogEntry[]): void { for (const entry of newEntries) { if (!this.entryMap.has(entry.insertId)) { - this.entryMap.set(entry.insertId, this.parseEntry(entry)); + const parsed = this.parseEntry(entry); + this.entryMap.set(entry.insertId, parsed); + this.queueRender(parsed); } } + this.rebuildMessages(); + } + /** Sort buffered messages oldest-first and evict the oldest beyond MAX_BUFFER. */ + private rebuildMessages(): void { 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() ); if (sorted.length > MAX_BUFFER) { - const evicted = sorted.splice(MAX_BUFFER); + // Drop the oldest (front) entries. + const evicted = sorted.splice(0, sorted.length - MAX_BUFFER); for (const e of evicted) { this.entryMap.delete(e.insertId); + this.renderedBodies.delete(e.insertId); } } this.messages = sorted; } + /** Render a message body to sanitized markdown HTML and cache it. */ + 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(); + }); + } + // --------------------------------------------------------------------------- // Streaming // --------------------------------------------------------------------------- @@ -664,7 +851,8 @@ export class ScionAgentMessageViewer extends LitElement { return; } this.composeText = ''; - // Refresh to pick up the newly sent message + // Jump to the latest after sending, then refresh to pick it up. + this.pinned = true; void this.fetchMessages(); } catch (err) { this.sendError = err instanceof Error ? err.message : 'Failed to send message'; @@ -684,6 +872,25 @@ export class ScionAgentMessageViewer extends LitElement { // UI handlers // --------------------------------------------------------------------------- + private get conversationEl(): HTMLElement | null { + return this.renderRoot?.querySelector('.conversation') ?? null; + } + + private handleScroll(): void { + const el = this.conversationEl; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48; + if (atBottom !== this.pinned) { + this.pinned = atBottom; + } + } + + private scrollToBottom(): void { + this.pinned = true; + const el = this.conversationEl; + if (el) el.scrollTop = el.scrollHeight; + } + private toggleExpand(insertId: string): void { if (this.expandedIds.has(insertId)) { this.expandedIds.delete(insertId); @@ -712,9 +919,21 @@ export class ScionAgentMessageViewer extends LitElement { override render() { return html` - ${this.canSend ? this.renderCompose() : nothing} - ${this.renderToolbar()} - ${this.renderContent()} +
+ ${this.renderToolbar()} +
+ ${this.renderContent()} + ${!this.pinned && this.messages.length > 0 + ? html` + + ` + : nothing} +
+ ${this.canSend ? this.renderCompose() : nothing} +
`; } @@ -727,19 +946,23 @@ export class ScionAgentMessageViewer extends LitElement { return html`
- ${isBroadcast ? html` -
- - Broadcast to all running agents in this project -
- ` : nothing} + ${isBroadcast + ? html` +
+ + Broadcast to all running agents in this project +
+ ` + : nothing}
{ this.composeText = (e.target as HTMLInputElement).value; }} + @sl-input=${(e: Event) => { + this.composeText = (e.target as HTMLInputElement).value; + }} @keydown=${this.handleComposeKeydown} ?disabled=${this.sending} > @@ -750,7 +973,9 @@ export class ScionAgentMessageViewer extends LitElement { { this.composePlain = (e.target as HTMLInputElement).checked; }} + @sl-change=${(e: Event) => { + this.composePlain = (e.target as HTMLInputElement).checked; + }} > Plain @@ -758,7 +983,9 @@ export class ScionAgentMessageViewer extends LitElement { { this.composeInterrupt = (e.target as HTMLInputElement).checked; }} + @sl-change=${(e: Event) => { + this.composeInterrupt = (e.target as HTMLInputElement).checked; + }} > Interrupt @@ -837,7 +1064,9 @@ export class ScionAgentMessageViewer extends LitElement { `; } - return html`
${this.renderMessages()}
`; + return html` +
${this.renderMessages()}
+ `; } private renderMessages() { @@ -846,7 +1075,11 @@ export class ScionAgentMessageViewer extends LitElement { for (const msg of this.messages) { 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; @@ -854,42 +1087,56 @@ export class ScionAgentMessageViewer extends LitElement { } const timeStr = d.toLocaleTimeString('en', { - hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + hour: '2-digit', + minute: '2-digit', }); const isExpanded = this.expandedIds.has(msg.insertId); + const isProjectView = !this.agentId; + const isUser = !isProjectView && msg.direction === 'received'; + + // Caption labels. Agent-scoped: agent name for its replies, sender for + // operator messages. Project-scoped: sender → recipient. + let actor: string; + let target = ''; + if (isProjectView) { + actor = msg.sender || 'unknown'; + target = msg.recipient || 'unknown'; + } else if (isUser) { + actor = msg.sender || 'You'; + } else { + actor = this.agentName || this.agentId; + } - const arrowIcon = msg.direction === 'sent' ? 'arrow-right' : 'arrow-left'; - const dirIcon = msg.direction === 'sent' ? 'box-arrow-up-right' : 'box-arrow-in-down-left'; - - // In agent-scoped view, show the current agent first with direction arrow. - // In project-scoped view (no agentId), always show sender → recipient. - const fromLabel = this.agentId - ? (this.agentName || this.agentId) - : (msg.sender || 'unknown'); - const toLabel = this.agentId - ? (msg.direction === 'sent' ? (msg.recipient || 'unknown') : (msg.sender || 'unknown')) - : (msg.recipient || 'unknown'); + const showType = !!msg.msgType && !COMMON_TYPES.has(msg.msgType); + const rendered = this.renderedBodies.get(msg.insertId); rows.push(html` -
this.toggleExpand(msg.insertId)}> -
- -
-
-
- ${fromLabel} - - ${toLabel} -
- ${msg.msgType ? html`${msg.msgType}` : nothing} - ${msg.urgent ? html`urgent` : nothing} - ${msg.broadcasted ? html`broadcast` : nothing} -
- ${timeStr} -
-
${this.truncateBody(msg.body)}
- ${isExpanded ? this.renderDetail(msg) : nothing} +
+
+ ${actor} + ${isProjectView + ? html`${target}` + : nothing} + ${timeStr} + ${showType ? html`${msg.msgType}` : nothing} + ${msg.urgent ? html`urgent` : nothing} + ${msg.broadcasted + ? html`broadcast` + : nothing} +
+ ${rendered !== undefined + ? html`
${unsafeHTML(rendered)}
` + : html`
${msg.body}
`} + ${isExpanded ? this.renderDetail(msg) : nothing}
`); } @@ -897,13 +1144,6 @@ export class ScionAgentMessageViewer extends LitElement { return rows; } - private truncateBody(body: string): string { - if (body.length > 300) { - return body.substring(0, 300) + '...'; - } - return body; - } - private renderDetail(msg: ParsedMessage) { const detail: Record = { timestamp: msg.timestamp, @@ -923,7 +1163,7 @@ export class ScionAgentMessageViewer extends LitElement { } } return html` -
e.stopPropagation()}> +
`; diff --git a/web/src/components/shared/markdown-preview.ts b/web/src/components/shared/markdown-preview.ts index fa0547b39..5547cd056 100644 --- a/web/src/components/shared/markdown-preview.ts +++ b/web/src/components/shared/markdown-preview.ts @@ -23,37 +23,7 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; - -// ──────────────────────────────────────────────────────────── -// Lazy-loaded markdown rendering -// ──────────────────────────────────────────────────────────── - -interface MarkdownRenderer { - render(markdown: string): string; -} - -let rendererPromise: Promise | null = null; - -async function loadRenderer(): Promise { - if (!rendererPromise) { - rendererPromise = (async () => { - 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 }) as string; - return purify.sanitize(rawHtml); - }, - }; - })(); - } - return rendererPromise; -} +import { renderMarkdown } from '../../shared/markdown.js'; // ──────────────────────────────────────────────────────────── // Component @@ -102,10 +72,22 @@ export class ScionMarkdownPreview extends LitElement { color: var(--scion-text, #1e293b); } - .preview-container h1 { font-size: 1.75rem; border-bottom: 1px solid var(--scion-border, #e2e8f0); padding-bottom: 0.3em; } - .preview-container h2 { font-size: 1.375rem; border-bottom: 1px solid var(--scion-border, #e2e8f0); padding-bottom: 0.3em; } - .preview-container h3 { font-size: 1.125rem; } - .preview-container h4 { font-size: 1rem; } + .preview-container h1 { + font-size: 1.75rem; + border-bottom: 1px solid var(--scion-border, #e2e8f0); + padding-bottom: 0.3em; + } + .preview-container h2 { + font-size: 1.375rem; + border-bottom: 1px solid var(--scion-border, #e2e8f0); + padding-bottom: 0.3em; + } + .preview-container h3 { + font-size: 1.125rem; + } + .preview-container h4 { + font-size: 1rem; + } .preview-container p { margin: 0 0 1em; @@ -242,8 +224,7 @@ export class ScionMarkdownPreview extends LitElement { this.error = null; try { - const renderer = await loadRenderer(); - this.renderedHtml = renderer.render(this.content); + this.renderedHtml = await renderMarkdown(this.content); } catch (err) { console.error('Failed to render markdown:', err); this.error = 'Failed to render markdown preview'; diff --git a/web/src/shared/markdown.ts b/web/src/shared/markdown.ts new file mode 100644 index 000000000..bc8484102 --- /dev/null +++ b/web/src/shared/markdown.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Shared markdown rendering helper. + * + * Renders raw markdown text to sanitized HTML using marked + DOMPurify. Both + * libraries are lazy-loaded on first use to keep the main bundle small, and the + * resolved renderer is cached for the lifetime of the page. The output is always + * passed through DOMPurify, so it is safe to inject via Lit's `unsafeHTML` + * directive or `.innerHTML`. + */ + +interface MarkdownRenderer { + render(markdown: string): string; +} + +let rendererPromise: Promise | null = null; + +async function loadRenderer(): Promise { + if (!rendererPromise) { + rendererPromise = (async () => { + 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); + }, + }; + })(); + } + return rendererPromise; +} + +/** + * Render markdown text to sanitized HTML. + * + * The returned HTML has been sanitized with DOMPurify and is safe to inject. + */ +export async function renderMarkdown(markdown: string): Promise { + const renderer = await loadRenderer(); + return renderer.render(markdown); +} From 7e64b77bae31a5c864822b6dd89a2d369dd55250 Mon Sep 17 00:00:00 2001 From: James Campbell Date: Wed, 3 Jun 2026 09:23:12 +0100 Subject: [PATCH 2/2] perf(web): cache message date/time and sort key at parse time Addresses review feedback on #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 --- .../components/shared/agent-message-viewer.ts | 46 +++++++++++++------ web/src/shared/markdown.ts | 2 + 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/web/src/components/shared/agent-message-viewer.ts b/web/src/components/shared/agent-message-viewer.ts index 43890985a..71de42611 100644 --- a/web/src/components/shared/agent-message-viewer.ts +++ b/web/src/components/shared/agent-message-viewer.ts @@ -64,10 +64,36 @@ interface ParsedMessage { urgent: boolean; broadcasted: boolean; timestamp: string; + /** Epoch ms of `timestamp`, derived once at parse time. Used for sorting so + * `rebuildMessages` (called per streamed message) never re-parses dates. */ + sortKey: number; + /** Pre-formatted date/time strings, derived once at parse time so the message + * list does not re-run Intl formatting on every render (e.g. each keystroke + * in the compose box, which re-renders the component). */ + dateStr: string; + timeStr: string; insertId: string; raw: MessageLogEntry | null; } +/** + * Derive the cached sort key and display strings from a message timestamp. + * Computed once per message at parse time rather than on every render/compare. + */ +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' }), + }; +} + const MAX_BUFFER = 500; /** Message types that are part of normal conversational flow; their type @@ -637,6 +663,7 @@ export class ScionAgentMessageViewer extends LitElement { urgent: msg.urgent ?? false, broadcasted: msg.broadcasted ?? false, timestamp: msg.createdAt, + ...deriveTimeFields(msg.createdAt), insertId: `hub:${msg.id}`, raw: null, }; @@ -699,6 +726,7 @@ export class ScionAgentMessageViewer extends LitElement { urgent, broadcasted, timestamp: entry.timestamp, + ...deriveTimeFields(entry.timestamp), insertId: entry.insertId, raw: entry, }; @@ -717,9 +745,7 @@ export class ScionAgentMessageViewer extends LitElement { /** Sort buffered messages oldest-first and evict the oldest beyond MAX_BUFFER. */ private rebuildMessages(): void { - const sorted = Array.from(this.entryMap.values()).sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); + const sorted = Array.from(this.entryMap.values()).sort((a, b) => a.sortKey - b.sortKey); if (sorted.length > MAX_BUFFER) { // Drop the oldest (front) entries. @@ -1074,23 +1100,15 @@ export class ScionAgentMessageViewer extends LitElement { let lastDate = ''; for (const msg of this.messages) { - const d = new Date(msg.timestamp); - const dateStr = d.toLocaleDateString('en', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); + // dateStr/timeStr are pre-computed at parse time (see deriveTimeFields) + // so this loop stays allocation-free on re-render. + const { dateStr, timeStr } = msg; if (dateStr !== lastDate) { lastDate = dateStr; rows.push(html`
${dateStr}
`); } - const timeStr = d.toLocaleTimeString('en', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - }); const isExpanded = this.expandedIds.has(msg.insertId); const isProjectView = !this.agentId; const isUser = !isProjectView && msg.direction === 'received'; diff --git a/web/src/shared/markdown.ts b/web/src/shared/markdown.ts index bc8484102..2a8e542d4 100644 --- a/web/src/shared/markdown.ts +++ b/web/src/shared/markdown.ts @@ -39,6 +39,8 @@ async function loadRenderer(): Promise { 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); },