From 5c127a1e3cbe4ae76a0eb9de160f425fab5ac088 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 17:20:09 +0300 Subject: [PATCH] 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. --- .../MarkdownRendererImpl.performance.test.tsx | 44 +++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 22 +++++- .../src/components/chat/markdown/decorate.ts | 15 ++++ .../chat/markdown/markdownCore.test.ts | 55 ++++++++++++++ .../components/chat/markdown/markdownCore.ts | 74 ++++++++++++++++++- .../chat/markdown/markdownSecurity.ts | 2 +- packages/ui/src/styles/design-system.css | 38 ++++++++++ 7 files changed, 245 insertions(+), 5 deletions(-) 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('