chat-adapters: DOM-returning render path + 4-adapter migration (#1100)#1106
Merged
joelteply merged 4 commits intoMay 13, 2026
Merged
Conversation
First step of CambrianTech#1100. Establishes the new adapter contract and proves it against the simplest, highest-traffic adapter. Behavior-preserving for all unmigrated adapters — they continue down the existing renderMessage()+innerHTML path. Contract change (AbstractMessageAdapter / MessageAdapter): - New optional method `renderMessageElement(): HTMLElement | null`. Default returns null = "fall back to the legacy string path." Adapters that override it return a fully-built wrapper element. - New helper `createAdapterWrapper()` for subclasses — produces the standard `message-content-adapter` host div with correct classes and data-content-type attribute, via DOM APIs (not by concatenating class names into a template string). TextMessageAdapter migration: - Overrides `renderMessageElement()`. Builds wrapper with the helper, runs the existing renderContent() pipeline (markdown → tool-use restoration → syntax highlighting → file-path linkify), and adopts the result via a module-level detached `<template>` element so the live message-content slot never sees an innerHTML assignment. - Sanitization model is unchanged: user text still goes through escapeHtmlInPlainText() before marked.parse(), tool-use parameters still pass through escapeHtml(), code blocks still go through hljs.highlight(). The only change is where the HTML is parsed — on a detached node, not on the live transcript. ChatWidget call site (the previously-flagged TODO): - Prefers the DOM path: adapter.renderMessageElement?.() → contentDiv.appendChild(node). Falls back to the legacy innerHTML path for adapters that haven't migrated. - Tiny side-fix: the no-adapter fallback was a string-concatenated <p>${text}</p> innerHTML — replaced with a textContent-set <p>. Out of scope (future PRs in CambrianTech#1100): ImageMessageAdapter, URLCardAdapter, ToolOutputAdapter, and the rest. Each migrates independently — call site already handles both paths. Not visually validated locally — TS compile is green; depends on a live deployment to confirm rendering parity. Test plan in the PR description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second step on CambrianTech#1100, on top of the TextMessageAdapter migration. Behavior-preserving (same class names, same data attributes, same structure that handleContentLoading() and the event delegator depend on), with a concrete security win: every interpolated attribute that the string path placed inside `"..."` now travels via DOM property / dataset assignment, and the caption — originating from user message text — goes through `.textContent` instead of `${caption}` in an element-content position. Specifically: - img.src / img.alt set as properties (no attribute-quote escapes needed; the browser handles it) - container.dataset.imageId / .mediaId via DOM API - download button: dataset.url / dataset.filename via DOM API - retry button: dataset.url via DOM API - caption: textContent, not innerHTML - emoji button labels: textContent, not template literal - action buttons gained aria-label (mirrors the title attribute, which screen readers don't reliably surface — same fix shape as the user-list buttons in the a11y PR) Structural identity preserved: - `.image-container`, `.image-loading-placeholder`, `.message-image`, `.image-error`, `.image-actions`, `.action-button` all kept - data-action attributes unchanged so MessageEventDelegator finds the same handlers - handleContentLoading() still finds `.message-image`, `.image-loading-placeholder`, `.image-error` via querySelector The legacy renderContent() string path is untouched — `renderMessage()` still returns the string-built version for any caller that hasn't adopted renderMessageElement yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
renderMessageElement(): HTMLElement | null) onAbstractMessageAdapter+MessageAdapterinterface — opt-in, falls back to the legacyrenderMessage()+innerHTMLpath when an adapter hasn't migrated.TextMessageAdapter,ImageMessageAdapter,URLCardAdapter,ToolOutputAdapter. Every chat content type that hits the live transcript now does so via DOM construction, notinnerHTML. The TODO at the ChatWidget call site is removed.Decisions made along the way
<template>. Sub-renderers already escape user input through their internalescapeHtml()so the security boundary is preserved.ChatWidget: the no-adapter fallback was`<p>${message.content?.text}</p>`injected viainnerHTML— small XSS hole for unknown content types. Replaced with a<p>+textContent.Commit-by-commit
chat-adapters: add DOM-returning render path, migrate TextMessageAdapter— contract change onAbstractMessageAdapter+MessageAdapterinterface. New protected helpercreateAdapterWrapper(). Text adapter overrides; markdown→tool-use→syntax-highlight→file-path-linkify pipeline unchanged, only the destination of the parsed HTML moves to a detached template.chat-adapters: migrate ImageMessageAdapter to DOM-returning render— every interpolated attribute (img.src,img.alt,dataset.imageId,dataset.mediaId,dataset.url,dataset.filename) moves to property/dataset assignment. Caption (user-controlled) moves to.textContent. Action buttons gainaria-labelmirroringtitle(same fix shape as the user-list a11y PR).fix(chat): lazily create text adapter parser— moves the text adapter's parser from module-level to function-local so the module remains importable in non-browser contexts.chat-adapters: migrate URLCardAdapter to DOM-returning render— concrete XSS fix: the string path interpolated${additionalText}(user message text) directly into element-content position. Every interpolated value now travels via DOM property / dataset / textContent.chat-adapters: migrate ToolOutputAdapter to DOM-returning render— scaffold via DOM, sub-renderer output via detached template, parser lazily created (consistent with the text adapter fix).data-tool-name/data-message-idvia dataset, status icon via textContent, inline tool images via property assignment.Why DOM over string
innerHTMLon a live element destroys any Lit-managed reactive children — adapter renders blow away signal-bound state in sibling rows..textContentpaths, not concatenated into HTML strings or attribute positions.<template>.innerHTMLparses once on a detached node, thencloneNode(true)produces a fragment to adopt — no live-tree re-parse.Structural identity preserved
Every adapter migration preserves:
handleContentLoading(),updateCardWithMetadata(), CSS, and the event delegator)data-actionattributes (soMessageEventDelegatorstill finds its handlers)handleCopy/handleOpenInTab/handleCardClick/ etc.) readdataset.*viaclosest()— works unchangedTest plan
npm run build:ts→ green (verified locally after each commit)<tool_use>block.data-actionbuttons still wire: open in fullscreen, download, retry, AI describe, copy tool output, open in tab, URL summarize, URL retry preview.Out of scope (follow-ups, not blockers)
innerHTML =on live transcript elements