Skip to content

feat(renderer): support sanitized html fragments#3052

Open
ht1072 wants to merge 1 commit into
nesquena:masterfrom
ht1072:feat/html-fragment-rendering-cachebust
Open

feat(renderer): support sanitized html fragments#3052
ht1072 wants to merge 1 commit into
nesquena:masterfrom
ht1072:feat/html-fragment-rendering-cachebust

Conversation

@ht1072
Copy link
Copy Markdown

@ht1072 ht1072 commented May 28, 2026

Thinking Path

  • Hermes WebUI's chat renderer already handles rich Markdown, MEDIA previews, Mermaid, and code blocks without a frontend build step.
  • Some assistant replies are easier to read as compact structured cards than as long vertical Markdown tables/lists.
  • Allowing arbitrary HTML would be unsafe and would also corrupt fenced-code examples, so this PR adds an explicit marker-based path with sanitization and stash ordering.
  • Mobile/PWA testing also exposed that local frontend hotfixes can keep serving old assets when the package version has not changed, so this PR makes the shell cache token include selected static-file mtimes.

What Changed

  • Adds an explicit assistant-message HTML fragment path using <!-- html-render-start --> / <!-- html-render-end -->.
  • Sanitizes rendered fragments by allow-listing tags/attributes and filtering inline styles for executable CSS/URLs.
  • Restores fragments only after fenced-code/backtick stashing, so examples inside ```html blocks remain source code instead of rendering live.
  • Adds calm-console fragment styling for headers, badges, grids, items, and notes using existing CSS variables, with narrow/mobile grid collapse.
  • Updates WebUI shell, login shell, and /sw.js cache tokens to include selected static/* mtimes, so local frontend hotfixes invalidate mobile/PWA caches even without a package version bump.
  • Adds renderer and PWA/cache-busting regression coverage.
  • Adds an Unreleased changelog entry.

Why It Matters

  • Gives agents a safe, explicit way to present dense status summaries, comparison cards, and structured UI evidence without turning the whole transcript into arbitrary raw HTML.
  • Keeps fenced-code documentation reliable: showing the marker syntax in a code block no longer triggers live rendering.
  • Reduces stale mobile/PWA cache failures after local frontend fixes, especially when ui.js or style.css changes between package releases.

Verification

  • python3 -m py_compile api/routes.py server.py
  • python3 -m pytest -q tests/test_renderer_js_behaviour.py tests/test_pwa_manifest_sw.py tests/test_static_asset_compression_and_cache.py
    • Result: 106 passed
  • Browser smoke test on an isolated local WebUI state dir:
    • Injected a sample assistant message through renderMd().
    • Confirmed .html-fragment-rendered exists for the marked fragment.
    • Confirmed #html-fragment-runtime-styles is injected.
    • Confirmed the same marker text inside a fenced html code block remains in <pre><code> and does not render as a live fragment.

UI / UX Evidence

  • Before: marked HTML could not be rendered as a structured assistant card; examples in fenced code would be indistinguishable from live marker syntax if handled too early.
  • After: explicitly marked fragments render as a compact bordered card using existing theme variables, while fenced-code examples stay source-only.
  • Responsive check: the fragment grid uses a narrow/mobile media query to collapse to one column.

Contract Routing

Task type: UI renderer behavior + PWA/mobile cache busting.

Touched areas:

  • static/ui.js assistant Markdown renderer
  • static/style.css transcript fragment styling
  • api/routes.py shell/service-worker asset version injection
  • renderer and PWA tests

Relevant public docs:

  • AGENTS.md
  • CONTRIBUTING.md
  • docs/CONTRACTS.md
  • docs/UIUX-GUIDE.md

Scope boundaries:

  • No frontend framework, bundler, dependency, or full-page HTML rendering added.
  • Fragment rendering is opt-in via explicit comment markers only.
  • Existing Markdown/code/MEDIA paths remain the default.

Risks / Follow-ups

  • This is intentionally a small allow-list sanitizer, not a general-purpose HTML sanitizer. The marker path should stay narrow and reviewed when new tags/attributes are added.
  • Styling is deliberately restrained to match the calm-console transcript direction; more complex visualization components should be added only with separate UX review.
  • Maintainers may want a follow-up docs snippet for agent authors if this marker syntax becomes a supported public authoring convention.

Model Used

  • AI-assisted by Hermes Agent using provider custom, model gpt-5.5.
  • Notable tool use: local git/worktree isolation, Python/pytest verification, GitHub API branch creation after HTTPS git push failed with transient TLS/network errors.

@ht1072 ht1072 force-pushed the feat/html-fragment-rendering-cachebust branch from 69a7338 to 0d7434e Compare May 28, 2026 07:09
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Triage: HOLD pending UX review — labels: hold, ux

This adds an opt-in HTML-fragment renderer for assistant messages (<!-- html-render-start --><!-- html-render-end -->) with a custom sanitizer. Two reasons we'd like to put this through deeper review before merge:

  1. Visible UI surface. A new always-on chat-render path that materializes assistant-emitted HTML into styled cards is a UX surface and benefits from screenshot evidence in 1280/390 viewports. Cards rendering inside fenced ```html blocks vs. live cards vs. legacy markdown all need a visual comparison.

  2. Renderer + sanitizer review. renderMd() in static/ui.js is one of the sharper tools in this codebase — every new stash pass interacts with the MEDIA stash, code-fence stash, pre stash, math stash, and the \x00H{n}\x00 placeholder replacement. We want our deep-renderMd reviewer to walk this carefully alongside the inline-style allowlist in _sanitizeHtmlFragment (specifically the safeStyle regex and the data-* passthrough).

The asset-mtime cache-bust token (_webui_asset_version_token) is a good independent improvement and we may pull that part out separately if it helps.

When you can:

  • Post mobile (390) + desktop (1280) screenshots of: a styled card via the new fragment, an inline <!-- html-render-start -->-fenced example, and a ```html code block (to confirm fenced examples stay source).
  • Confirm that nested <!-- html-render-start --> blocks inside a code fence still render as source.

Thanks for the work — this is a reasonable feature and the sanitizer scope looks deliberate. Just needs the deeper renderer pass.

@ht1072
Copy link
Copy Markdown
Author

ht1072 commented May 29, 2026

Thanks for the review — agreed that this should sit in UX/renderMd hold until the renderer pass is comfortable.

I added the requested visual evidence from an isolated local WebUI run. The fixture uses the real static/ui.js renderMd() path and static/style.css; it covers:

  • live styled card via <!-- html-render-start --> ... <!-- html-render-end -->
  • inline escaped marker example
  • fenced ```html block containing nested html-render-start/end markers, confirming it remains source-only

Desktop 1280:

HTML fragment desktop 1280

Mobile 390:

HTML fragment mobile 390

Repro fixture used for the screenshots:

https://raw.githubusercontent.com/ht1072/hermes-webui/evidence/pr-3052-html-fragment/pr-evidence/3052/pr-evidence.html

A few confirmations:

  • Nested <!-- html-render-start --> blocks inside a fenced html code block stay source-only because the fragment pass runs after fenced-code/backtick stashing.
  • The sample includes the marker inside the fenced block and it renders as <pre><code>, not as .html-fragment-rendered.
  • I also recut the PR branch onto the current master head after upstream advanced; the PR is now back to mergeable=true with one commit and six files changed.

Local verification after the recut:

python3 -m py_compile api/routes.py server.py
python3 -m pytest -q tests/test_renderer_js_behaviour.py tests/test_pwa_manifest_sw.py tests/test_static_asset_compression_and_cache.py
106 passed

CI on GitHub is still action_required with zero jobs/check-runs, which looks like the fork workflow approval gate rather than a test failure.

Happy to split _webui_asset_version_token() into a smaller independent PR if that path is easier to land first while the HTML-fragment renderer gets deeper review.

- render explicit sanitized assistant HTML fragments
- keep html fragment markers inside fenced code source-only
- include static file mtimes in WebUI/PWA cache-bust tokens
- make fragment cards width-safe on narrow mobile viewports
@ht1072 ht1072 force-pushed the feat/html-fragment-rendering-cachebust branch from a9cd202 to 57e0bed Compare May 29, 2026 01:31
@ht1072
Copy link
Copy Markdown
Author

ht1072 commented May 29, 2026

Good catch on the mobile screenshot. That exposed a real narrow-width issue rather than just a screenshot problem.

I updated the PR branch with a mobile width-safety fix for the fragment styles:

  • .html-fragment-rendered now has width:100%; max-width:min(720px,100%); min-width:0;
  • the card root also gets width:100%; min-width:0;
  • header/grid/items now carry min-width:0
  • title/subtitle/item/note text now uses overflow-wrap:anywhere
  • the same rules are mirrored in the runtime-injected style block in static/ui.js so cached style.css does not leave the fragment path broken
  • added regression coverage asserting both static/style.css and the runtime styles contain the mobile width-safety rules

Updated verification:

node --check static/ui.js
python3 -m pytest -q tests/test_renderer_js_behaviour.py tests/test_pwa_manifest_sw.py tests/test_static_asset_compression_and_cache.py
107 passed

I also refreshed the mobile evidence image at the same URL:

HTML fragment mobile 390

PR shape after the update:

head: 57e0bedb1374702676b0e7fd65820ba889c19ac0
base: cf003ae98699263aef05a99291daf10aee717809
mergeable: true
mergeable_state: unstable
changed_files: 6

unstable is still the fork Actions approval gate, not a failing job.

@ht1072
Copy link
Copy Markdown
Author

ht1072 commented May 29, 2026

Replaced the mobile evidence image with a true Chrome DevTools Protocol mobile-emulation capture rather than a plain narrow headless window.

Capture settings:

width: 390
mobile: true
deviceScaleFactor: 3
UA: Android / Pixel-style Chrome

Layout metrics from the capture:

{
  "innerWidth": 390,
  "visualViewportWidth": 390,
  "dpr": 3,
  "docScrollWidth": 390,
  "bodyClientWidth": 390,
  "msgBody": { "clientWidth": 366, "scrollWidth": 366 },
  "card": { "clientWidth": 366, "scrollWidth": 366 },
  "grid": { "clientWidth": 332, "scrollWidth": 332, "columns": "332px" }
}

Updated mobile image, same URL as before:

HTML fragment mobile 390

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold ux User experience / visual polish

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants