feat(chat): render <details>/<summary> blocks as collapsible sections
Build / build (push) Waiting to run
Playwright / e2e (push) Waiting to run

markdown-it runs with html: false, so raw HTML in a message body is
escaped and shown as literal text. Agents legitimately use
<details>/<summary> to hide the long tail of a reply, and it rendered
as a wall of visible tags.

MarkdownRenderer now splits the message into plain segments and
<details> blocks, renders the plain segments through markdown-it as
before, and builds real <details> elements from the blocks. Nested
blocks are handled by recursion; an unclosed tag does not swallow the
rest of the message.

The html option stays false on purpose: enabling it would let model
output inject arbitrary markup into v-html. Every fragment still goes
through markdown-it, so nothing unsanitised reaches the DOM.

Group chat shares this component, so it picks the change up too.
This commit is contained in:
Jack
2026-11-04 23:48:21 +08:00
parent 318fa561fe
commit 586776998b
2 changed files with 154 additions and 1 deletions
@@ -8,6 +8,7 @@ import katex from 'katex'
import markdownItKatex from '@vscode/markdown-it-katex'
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
import { repairNestedMarkdownFences } from './markdownFenceRepair'
import { splitDetails } from './markdownDetails'
import {
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
MERMAID_MAX_SOURCE_LENGTH,
@@ -143,8 +144,28 @@ function hasExtension(path: string, extensions: Set<string>): boolean {
return !!ext && extensions.has(ext)
}
// Render markdown, but turn <details>/<summary> blocks into real collapsible
// elements. markdown-it runs with `html: false`, so raw HTML in the message
// body would otherwise be escaped and shown as literal text. Every fragment
// still goes through markdown-it, so nothing unsanitised reaches v-html.
function renderWithDetails(content: string): string {
const segments = splitDetails(content)
if (segments.length === 1 && segments[0].kind === 'text') {
return md.render(content)
}
return segments.map((segment) => {
if (segment.kind === 'text') {
return md.render(segment.value)
}
const summaryHtml = md.renderInline(segment.block.summary.trim())
const bodyHtml = renderWithDetails(segment.block.body)
return `<details class="markdown-details"><summary class="markdown-details-summary">${summaryHtml}</summary><div class="markdown-details-body">${bodyHtml}</div></details>`
}).join('')
}
const renderedHtml = computed(() => {
let html = md.render(repairNestedMarkdownFences(props.content))
let html = renderWithDetails(repairNestedMarkdownFences(props.content))
// Add IDs to headings for anchor links
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
@@ -585,6 +606,46 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
margin: 8px 0;
}
.markdown-details {
margin: 8px 0;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $code-bg;
overflow: hidden;
}
.markdown-details-summary {
cursor: pointer;
padding: 8px 12px;
font-weight: 600;
color: $text-primary;
user-select: none;
list-style: revert;
display: list-item;
&::marker {
color: $text-secondary;
}
&:hover {
background: rgba(var(--bg-card-rgb), 0.6);
}
}
.markdown-details-body {
padding: 8px 12px 4px;
border-top: 1px solid $border-color;
color: $text-primary;
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
.markdown-video-container {
margin: 12px 0;
border-radius: $radius-sm;
@@ -0,0 +1,92 @@
// Collapsible <details>/<summary> blocks inside markdown messages.
//
// markdown-it runs with `html: false`, so raw HTML in a message body is
// escaped and shown as literal text. That is the right default for LLM
// output (no script injection), but it also kills the one HTML construct
// agents legitimately use to hide the long tail of a reply.
//
// This module splits the raw message into plain text segments and
// <details> blocks. The caller renders the text segments through
// markdown-it as usual and substitutes the details blocks with real
// <details> HTML built from markdown-rendered inner content. Everything
// that ends up in the DOM still went through markdown-it, so no unsanitised
// model output reaches v-html.
export interface DetailsBlock {
summary: string
body: string
}
export type DetailsSegment =
| { kind: 'text'; value: string }
| { kind: 'details'; block: DetailsBlock }
const OPEN_TAG = /<details\b[^>]*>/i
const SUMMARY_TAG = /<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i
const TAG_SCAN = /<details\b[^>]*>|<\/details\s*>/gi
/** Marker that survives markdown rendering untouched, one per details block. */
export function detailsToken(index: number): string {
return `hermes-details-block-${index}`
}
/**
* Split `src` into plain text and <details> segments.
*
* Nested <details> blocks are supported: the inner blocks end up inside the
* outer block body and are split again when that body is rendered.
*/
export function splitDetails(src: string): DetailsSegment[] {
const segments: DetailsSegment[] = []
let cursor = 0
while (cursor < src.length) {
const rest = src.slice(cursor)
const open = OPEN_TAG.exec(rest)
if (!open) {
segments.push({ kind: 'text', value: src.slice(cursor) })
break
}
const openIndex = cursor + open.index
if (openIndex > cursor) {
segments.push({ kind: 'text', value: src.slice(cursor, openIndex) })
}
const innerStart = openIndex + open[0].length
const innerEnd = findMatchingClose(src, innerStart)
const inner = src.slice(innerStart, innerEnd)
const summaryMatch = SUMMARY_TAG.exec(inner)
const summary = summaryMatch ? summaryMatch[1] : ''
const body = summaryMatch
? inner.slice(0, summaryMatch.index) + inner.slice(summaryMatch.index + summaryMatch[0].length)
: inner
segments.push({ kind: 'details', block: { summary, body } })
cursor = innerEnd + '</details>'.length
}
return segments
}
/**
* Index of the `</details>` that closes the block opened before `from`.
* Returns src.length when the tag is missing, so an unclosed block still
* renders instead of swallowing the rest of the message.
*/
function findMatchingClose(src: string, from: number): number {
let depth = 1
TAG_SCAN.lastIndex = from
let match: RegExpExecArray | null
while ((match = TAG_SCAN.exec(src)) !== null) {
if (match[0][1] === '/') {
depth -= 1
if (depth === 0) return match.index
} else {
depth += 1
}
}
return src.length
}