diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx index 1c0e1616a11..b5d35f04558 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx @@ -125,4 +125,67 @@ describe("ChunkedStreamingMarkdown", () => { expect(expected.length).toBeGreaterThan(100); expect(chunked).toEqual(expected); }); + + // #5664: a loose list longer than a chunk, streamed item by item. Some frames end mid-marker + // (`…\n\n30` before the `.`), where the partial marker parses as a paragraph after the list. + test.each([ + ["ordered", (k: number) => `${k}.`], + ["bullet", () => "-"], + ])( + "a loose %s list streamed across chunks renders like one static render", + async (_kind, marker) => { + const items = Array.from( + { length: 60 }, + (_, i) => + `${marker(i + 1)} Item ${i + 1} with **bold** and more words, lorem ipsum dolor sit.` + ); + const reply = items.join("\n\n"); + const renderRow = (content: string, isStreaming: boolean) => + flushSync(() => + root?.render( + + + + ) + ); + let text = ""; + for (const item of items) { + if (text.length > 0) { + text += "\n\n"; + // Every partial marker is its own frame, then the whole item. + const space = item.indexOf(" "); + for (let end = 1; end <= space; end++) renderRow(text + item.slice(0, end), true); + } + text += item; + renderRow(text, true); + } + renderRow(reply, false); + for (let i = 0; i < 100 && !container.textContent?.includes("Item 1 with"); i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + // Structure, not just text: one list with a

in every item, exactly as one render. + const outline = (element: Element) => + [...element.querySelectorAll("ol, ul, li, li > p")].map( + (node) => `${node.tagName}${node.getAttribute("start") ?? ""}` + ); + const single = document.createElement("div"); + document.body.appendChild(single); + const singleRoot = createRoot(single); + flushSync(() => + singleRoot.render( + + + + ) + ); + const expected = outline(single); + const expectedText = single.textContent?.replace(/\s+/g, ""); + flushSync(() => singleRoot.unmount()); + + expect(expected.filter((tag) => tag === "LI")).toHaveLength(60); + expect(outline(container)).toEqual(expected); + expect(container.textContent?.replace(/\s+/g, "")).toBe(expectedText); + } + ); }); diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx index 4441138b628..2dc083b8a0d 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx @@ -8,6 +8,9 @@ import { import { MarkdownCore } from "./MarkdownCore"; import { normalizeMarkdown } from "./MarkdownStyles"; +// Blank lines, or the digits of an ordered list marker before its `.` or `)` arrives. +const PARTIAL_LIST_MARKER = /^\d{0,9}$/; + /** * Splits growing markdown into chunks of whole Streamdown blocks, about `maxChars` each, and * joins back to the input. A chunk is sealed once a later block follows it: the last block can @@ -32,8 +35,19 @@ export class MarkdownChunker { } const groups: string[] = []; let current = ""; - for (const block of parseMarkdownIntoBlocks(text.slice(this.sealedText.length))) { - if (current.length > 0 && current.length + block.length > this.maxChars) { + const blocks = parseMarkdownIntoBlocks(text.slice(this.sealedText.length)); + // A tail that is only a partial list marker (`…\n\n30` before its `.`) parses as a paragraph + // after the list, but joins the list once the marker is complete (#5664). So that tail and + // the blank lines before it never start a group: sealing the list there would make the next + // item start a second list. + let tailStart = blocks.length; + while (tailStart > 0 && PARTIAL_LIST_MARKER.test(blocks[tailStart - 1].trim())) tailStart--; + for (const [index, block] of blocks.entries()) { + if ( + index < tailStart && + current.length > 0 && + current.length + block.length > this.maxChars + ) { groups.push(current); current = ""; } @@ -103,7 +117,13 @@ export const ChunkedStreamingMarkdown: React.FC =

{chunks.slice(start).map((chunk, offset) => ( . A chunk therefore mounts fresh when it is sealed and when the + // stream ends (#5664). A sealed chunk keeps its key, so MarkdownCore's memo still skips it. + key={ + props.isStreaming && start + offset === lastIndex ? `open-${lastIndex}` : start + offset + } content={chunk} // Only the open last chunk can hold incomplete markdown. parseIncompleteMarkdown={props.isStreaming && start + offset === lastIndex}