feat(chat): support collapsible Markdown disclosures
Render details and summary as controlled disclosures with rich Markdown and shared sprite chevrons. Preserve expansion through streaming, settlement, and redecorating while keeping other raw HTML inert. Validated with 59 focused tests, UI type-check and lint, and maintainer testing during live generation. Dead-code and oxlint reports retain existing findings.
This commit is contained in:
@@ -326,6 +326,50 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||
test('preserves disclosure choices through streaming, settlement, and redecorating', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
const prefix = 'Introduction\n\n<details><summary>Review</summary>\n\n';
|
||||
const render = async (content: string, streaming: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(<MarkdownRenderer content={content} messageId="disclosures" isAnimated={false} isStreaming={streaming} enableFileReferences={false} />);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => waitForSettledEffects());
|
||||
};
|
||||
try {
|
||||
await render(`${prefix}First`, true);
|
||||
const first = host.querySelector<HTMLDetailsElement>('details');
|
||||
expect(first).not.toBeNull();
|
||||
expect(first?.open).toBe(false);
|
||||
expect(first?.querySelector('summary [data-md-disclosure-icon] use')?.getAttribute('href')).toBe('#oc-arrow-right-s');
|
||||
if (!first) throw new Error('Expected disclosure');
|
||||
first.open = true;
|
||||
for (let count = 1; count <= 5; count += 1) {
|
||||
await render(`${prefix}First\n\n${'More text. '.repeat(count)}`, true);
|
||||
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(true);
|
||||
}
|
||||
const settled = `${prefix}First\n\n</details>\n\n<details open><summary>Second</summary>\n\nBody\n\n</details>`;
|
||||
await render(settled, false);
|
||||
const disclosures = host.querySelectorAll<HTMLDetailsElement>('details');
|
||||
expect(disclosures).toHaveLength(2);
|
||||
expect(disclosures[0]?.open).toBe(true);
|
||||
expect(disclosures[1]?.open).toBe(true);
|
||||
disclosures[1]!.open = false;
|
||||
// The fixture supplies a fresh theme/translation context on each render,
|
||||
// exercising whole-block replacement with unchanged source as well.
|
||||
await render(settled, false);
|
||||
expect(host.querySelectorAll<HTMLDetailsElement>('details')[0]?.open).toBe(true);
|
||||
expect(host.querySelectorAll<HTMLDetailsElement>('details')[1]?.open).toBe(false);
|
||||
expect(host.querySelectorAll('summary [data-md-disclosure-icon]')).toHaveLength(2);
|
||||
await render('<details><summary>Different</summary>\n\nNew body\n\n</details>', false);
|
||||
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
test('fixes body-sized table columns once the stream settles', async () => {
|
||||
const content = [
|
||||
'| An intentionally oversized header | Another oversized header | A third oversized header |',
|
||||
|
||||
@@ -1019,6 +1019,12 @@ const useMorphdomMarkdown = ({
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
// Capture before block reconciliation: streaming completion changes the
|
||||
// wrapper layout, and theme changes can replace entire decorated blocks.
|
||||
// Match by disclosure order plus heading so unrelated replacements cannot
|
||||
// inherit the previous disclosure's state. No persistent/global state.
|
||||
const disclosureStates = Array.from(target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]'))
|
||||
.map((details) => ({ summary: details.querySelector('summary')?.textContent, open: details.open }));
|
||||
|
||||
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
||||
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
|
||||
@@ -1081,7 +1087,13 @@ const useMorphdomMarkdown = ({
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (fromEl.matches('details[data-md-details]') && toEl.matches('details[data-md-details]')
|
||||
&& fromEl.querySelector('summary')?.textContent === toEl.querySelector('summary')?.textContent) {
|
||||
toEl.toggleAttribute('open', fromEl.hasAttribute('open'));
|
||||
}
|
||||
return !fromEl.isEqualNode(toEl);
|
||||
},
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
@@ -1102,6 +1114,14 @@ const useMorphdomMarkdown = ({
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
if (disclosureStates.length > 0) {
|
||||
target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]').forEach((details, index) => {
|
||||
const previous = disclosureStates[index];
|
||||
if (previous && previous.summary === details.querySelector('summary')?.textContent) {
|
||||
details.open = previous.open;
|
||||
}
|
||||
});
|
||||
}
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
|
||||
@@ -52,6 +52,7 @@ const ICONS = {
|
||||
fit: 'refresh',
|
||||
textWrap: 'text-wrap',
|
||||
image: 'file-image',
|
||||
disclosure: 'arrow-right-s',
|
||||
} as const satisfies Record<string, IconName>;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
@@ -81,6 +82,19 @@ const decorateImageLabels = (root: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const decorateDisclosures = (root: HTMLElement): void => {
|
||||
for (const summary of root.querySelectorAll<HTMLElement>('details[data-md-details] > summary')) {
|
||||
if (summary.querySelector('[data-md-disclosure-icon]')) continue;
|
||||
const label = document.createElement('span');
|
||||
label.append(...Array.from(summary.childNodes));
|
||||
const icon = document.createElement('span');
|
||||
icon.setAttribute('data-md-disclosure-icon', '');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
setIcon(icon, 'disclosure');
|
||||
summary.append(icon, label);
|
||||
}
|
||||
};
|
||||
|
||||
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
@@ -611,6 +625,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
|
||||
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
decorateDisclosures(root);
|
||||
decorateImageLabels(root);
|
||||
decorateInlineCode(root);
|
||||
decorateMermaid(root, ctx);
|
||||
|
||||
@@ -105,6 +105,61 @@ describe('markdown sanitization', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown disclosures', () => {
|
||||
test('renders summaries and rich Markdown without allowing raw HTML attributes', () => {
|
||||
const html = renderMarkdownSync('<details open><summary>Review **ready**</summary>\n\n> Quoted review\n\n1. First\n2. Second\n\n```sh\nbun test\n```\n\n</details>\n\nAfter');
|
||||
expect(html).toContain('<details data-md-details open>');
|
||||
expect(html).toContain('<summary>Review <strong>ready</strong></summary>');
|
||||
expect(html).toContain('<blockquote>');
|
||||
expect(html).toContain('<ol>');
|
||||
expect(html).toContain('<code class="language-sh">bun test');
|
||||
expect(html).toContain('</details><p>After</p>');
|
||||
const unsafe = renderMarkdownSync('<details onclick="alert(1)"><summary>Unsafe</summary>text</details>');
|
||||
expect(unsafe).not.toContain('<details');
|
||||
expect(unsafe).toContain('<details');
|
||||
expect(renderMarkdownSync('<details><summary>Safe</summary>\n\n<style>body{display:none}</style>\n\n</details>')).not.toContain('<style>');
|
||||
});
|
||||
|
||||
test('keeps nested disclosures and literal closing tags inside code in their owner', () => {
|
||||
const source = '<details><summary>Outer</summary>\n\n`</details>`\n\n```html\n</details>\n```\n\n<details open><summary>Inner</summary>\n\n**Nested**\n\n</details>\n\nOuter end\n\n</details>\n\nAfter';
|
||||
const html = renderMarkdownSync(source);
|
||||
expect(html.match(/<details /g)).toHaveLength(2);
|
||||
expect(html).toContain('<code></details></code>');
|
||||
expect(html).toContain('<strong>Nested</strong>');
|
||||
expect(html).toContain('</details><p>Outer end</p>');
|
||||
expect(html).toContain('</details><p>After</p>');
|
||||
expect(renderMarkdownSync('```html\n<details><summary>Example</summary></details>\n```')).not.toContain('<details');
|
||||
});
|
||||
|
||||
test('keeps streamed bodies together and settled leading blocks cache-stable', async () => {
|
||||
const prefix = 'Introduction\n\n<details><summary>Review</summary>\n\n';
|
||||
const first = await renderMarkdownBlocks(`${prefix}> First\n\n1. Item`, true);
|
||||
const next = await renderMarkdownBlocks(`${prefix}> First\n\n1. Item\n2. More\n\n\`\`\`sh\nbun test`, true);
|
||||
expect(first).toHaveLength(2);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[0]).toEqual(first[0]);
|
||||
expect(next[1]?.html).toContain('<details data-md-details>');
|
||||
expect(next[1]?.html).toContain('<li>More</li>');
|
||||
expect(next[1]?.html).toContain('bun test');
|
||||
expect(next[1]?.html.endsWith('</details>')).toBe(true);
|
||||
const finished = await renderMarkdownBlocks(`${prefix}> First\n\n</details>\n\nAfter`, true);
|
||||
expect(finished).toHaveLength(3);
|
||||
expect(finished[2]?.html).toContain('<p>After</p>');
|
||||
});
|
||||
|
||||
test('handles incomplete summary and closing tag prefixes without losing content', async () => {
|
||||
const source = '<details><summary>Review</summary>\n\n**Body**\n\n</details>';
|
||||
for (let length = 1; length <= source.length; length += 1) {
|
||||
const blocks = await renderMarkdownBlocks(source.slice(0, length), true);
|
||||
const html = blocks.map((block) => block.html).join('');
|
||||
if (length >= source.indexOf('\n\n')) expect(html).toContain('<details data-md-details>');
|
||||
if (length >= source.indexOf('\n\n</details>')) {
|
||||
expect(html).toContain('<strong>Body</strong>');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown block cache reads', () => {
|
||||
test('returns all settled blocks synchronously after a full cache hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import { Marked, marked, type Tokens, type TokenizerAndRendererExtension } from 'marked';
|
||||
import markedLinkifyIt from 'marked-linkify-it';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
@@ -233,7 +233,7 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
|
||||
let tokens: Tokens.Generic[];
|
||||
try {
|
||||
tokens = marked.lexer(text) as Tokens.Generic[];
|
||||
tokens = inlineImageParser.lexer(text);
|
||||
} catch {
|
||||
return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
|
||||
}
|
||||
@@ -342,6 +342,74 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
// Own the entire disclosure token, including an unfinished streamed body. HTML
|
||||
// token boundaries otherwise split it at blank lines and close the DOM early.
|
||||
const detailsExtension: TokenizerAndRendererExtension = {
|
||||
name: 'disclosure',
|
||||
level: 'block',
|
||||
start(src) {
|
||||
const match = /(?:^|\n) {0,3}<details(?:\s|>)/i.exec(src);
|
||||
return match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : undefined;
|
||||
},
|
||||
tokenizer(src) {
|
||||
// Only the native boolean open attribute is accepted. Never forward raw
|
||||
// attributes, styles, event handlers, or an arbitrary HTML subtree.
|
||||
const opening = /^ {0,3}<details(?:\s+(open(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?))?\s*>\s*<summary\s*>([\s\S]*?)<\/summary\s*>/i.exec(src);
|
||||
if (!opening) return undefined;
|
||||
const bodyStart = opening[0].length;
|
||||
const body = src.slice(bodyStart);
|
||||
const markers = /(^ {0,3}(`{3,}|~{3,})[^\n]*(?:\n|$))|(`+)|(<\/?details\b[^>]*>)/gim;
|
||||
let depth = 1;
|
||||
let bodyEnd = body.length;
|
||||
let end = src.length;
|
||||
let marker: RegExpExecArray | null;
|
||||
while ((marker = markers.exec(body))) {
|
||||
if (marker[2]) {
|
||||
const fence = marker[2];
|
||||
const close = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[\\t ]*(?:\\n|$)`, 'gm');
|
||||
close.lastIndex = markers.lastIndex;
|
||||
const found = close.exec(body);
|
||||
if (!found) break;
|
||||
markers.lastIndex = close.lastIndex;
|
||||
} else if (marker[3]) {
|
||||
const ticks = marker[3];
|
||||
const close = /`+/g;
|
||||
close.lastIndex = markers.lastIndex;
|
||||
let found: RegExpExecArray | null;
|
||||
while ((found = close.exec(body))) {
|
||||
if (found[0].length === ticks.length) {
|
||||
markers.lastIndex = close.lastIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (marker[4]) {
|
||||
const lineStart = body.lastIndexOf('\n', marker.index - 1) + 1;
|
||||
const prefix = body.slice(lineStart, marker.index);
|
||||
// Quoted and indented code belongs to the child Markdown parser. Its
|
||||
// HTML-looking text must not terminate the surrounding disclosure.
|
||||
if (/^(?: {4}|\t| {0,3}>)/.test(prefix) || /(?:^|[^\\])(?:\\\\)*\\$/.test(prefix)) continue;
|
||||
depth += /^<\//.test(marker[4]) ? -1 : 1;
|
||||
if (depth === 0) {
|
||||
bodyEnd = marker.index;
|
||||
end = bodyStart + markers.lastIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'disclosure',
|
||||
raw: src.slice(0, end),
|
||||
open: Boolean(opening[1]),
|
||||
summary: this.lexer.inlineTokens(opening[2] ?? ''),
|
||||
tokens: this.lexer.blockTokens(body.slice(0, bodyEnd)),
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
return `<details data-md-details${token.open ? ' open' : ''}><summary>${this.parser.parseInline(token.summary)}</summary>${this.parser.parse(token.tokens ?? [])}</details>`;
|
||||
},
|
||||
childTokens: ['summary', 'tokens'],
|
||||
};
|
||||
|
||||
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
|
||||
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
|
||||
// Plain CJK characters right after a URL are still consumed, matching GitHub.
|
||||
@@ -350,7 +418,7 @@ const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
|
||||
{
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
extensions: [inlineMathExtension, blockMathExtension, detailsExtension],
|
||||
renderer: {
|
||||
// Assistant output is untrusted. Markdown constructs still render as HTML,
|
||||
// but raw HTML must remain visible text so it cannot introduce active DOM
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Raw HTML in assistant markdown is untrusted and must stay inert text. */
|
||||
/** Raw HTML stays inert; supported disclosures are constructed by the Markdown tokenizer. */
|
||||
export const escapeRawMarkdownHtml = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
|
||||
@@ -157,6 +157,44 @@
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, 'Liberation Mono', Menlo, monospace) !important;
|
||||
}
|
||||
|
||||
.markdown-content details[data-md-details] {
|
||||
margin-block: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.markdown-content details[data-md-details] > summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding-block: 0.375rem;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 500;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content details[data-md-details] > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-content [data-md-disclosure-icon] {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25em;
|
||||
color: var(--surface-muted-foreground);
|
||||
}
|
||||
|
||||
.markdown-content details[data-md-details][open] > summary > [data-md-disclosure-icon] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.markdown-content details[data-md-details] > summary:focus-visible {
|
||||
outline: 2px solid var(--interactive-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Restore list styling inside markdown content - override Tailwind preflight.
|
||||
Native markers with a compact gutter; nesting cycles the marker shape so
|
||||
depth stays readable without extra indentation. */
|
||||
|
||||
Reference in New Issue
Block a user