diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx index 285cdebe..31303b64 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx @@ -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
Review\n\n'; + const render = async (content: string, streaming: boolean) => { + await act(async () => { + root.render(); + await waitForSettledEffects(); + }); + await act(async () => waitForSettledEffects()); + }; + try { + await render(`${prefix}First`, true); + const first = host.querySelector('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('details')?.open).toBe(true); + } + const settled = `${prefix}First\n\n
\n\n
Second\n\nBody\n\n
`; + await render(settled, false); + const disclosures = host.querySelectorAll('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('details')[0]?.open).toBe(true); + expect(host.querySelectorAll('details')[1]?.open).toBe(false); + expect(host.querySelectorAll('summary [data-md-disclosure-icon]')).toHaveLength(2); + await render('
Different\n\nNew body\n\n
', false); + expect(host.querySelector('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 |', diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 8b2a57ec..0137d718 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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('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('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; diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 56ea8b69..fcd5b4b1 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -52,6 +52,7 @@ const ICONS = { fit: 'refresh', textWrap: 'text-wrap', image: 'file-image', + disclosure: 'arrow-right-s', } as const satisfies Record; const ICON_BTN_CLASS = @@ -81,6 +82,19 @@ const decorateImageLabels = (root: HTMLElement): void => { } }; +const decorateDisclosures = (root: HTMLElement): void => { + for (const summary of root.querySelectorAll('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); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index fa2a2aad..3b541899 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -105,6 +105,61 @@ describe('markdown sanitization', () => { }); +describe('Markdown disclosures', () => { + test('renders summaries and rich Markdown without allowing raw HTML attributes', () => { + const html = renderMarkdownSync('
Review **ready**\n\n> Quoted review\n\n1. First\n2. Second\n\n```sh\nbun test\n```\n\n
\n\nAfter'); + expect(html).toContain('
'); + expect(html).toContain('Review ready'); + expect(html).toContain('
'); + expect(html).toContain('
    '); + expect(html).toContain('bun test'); + expect(html).toContain('

After

'); + const unsafe = renderMarkdownSync('
Unsafetext
'); + expect(unsafe).not.toContain('Safe\n\n\n\n')).not.toContain('