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}