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\nReview
\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\nSecond
\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('Unsafe
text ');
+ expect(unsafe).not.toContain('Safe
\n\n\n\n ')).not.toContain('