From 1fbdac728f358b2fea028be03e8daeab0851f71f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 06:59:48 +0200 Subject: [PATCH] fix(app): keep growing markdown on the streaming block renderer --- docs/agent-stream-performance.md | 8 +- docs/native-text-selection.md | 4 +- .../app/src/agent-stream/presentation.test.ts | 90 +++++++-- packages/app/src/agent-stream/presentation.ts | 14 +- .../components/markdown-prose/prepare.test.ts | 45 +---- .../src/components/markdown-prose/prepare.ts | 176 ++---------------- .../src/components/markdown-prose/props.ts | 1 - .../markdown-prose/renderer.native.tsx | 5 +- .../app/src/components/markdown-prose/runs.ts | 61 ++++-- .../markdown-prose/source-inline.ts | 32 +--- packages/app/src/components/message.tsx | 1 - 11 files changed, 162 insertions(+), 275 deletions(-) diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 8aa03c89e2..02d9a42848 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -31,14 +31,16 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. - **Markdown rows belong to presentation, and every assistant message is a block group.** `agent-stream/presentation.ts` keeps the source message whole for plugins, then splits it for - display. Native groups adjacent prose into selectable runs; web retains individual blocks. + display. Native groups completed adjacent prose into selectable runs; web retains individual blocks. + The growing last source block stays on the existing block renderer and streaming parser. + It joins the preceding run when the next block starts or the turn ends. Code, tables, images, HTML-like content and blockquotes end a native run. See [native text selection](native-text-selection.md) for renderer ownership and library constraints. Appends reparse the growing last block. A completed reference definition at its start can belong to the preceding block, so that case reparses the full message. Completed rows retain object identity and unchanged history stays cached. Prose rows retain their original source - blocks so native preparation cannot broaden reference scope. Paced reveal reuses prepared - paragraph prefixes and prepares only the remaining open inline tail. + blocks so native preparation cannot broaden reference scope. Completed blocks are classified + and prepared once; the growing block is neither classified nor prepared for the prose renderer. - **A row id is never a message id.** Block rows are `${messageId}:block:${n}`; native prose runs use `${messageId}:run:${n}` with `blockGroupId = messageId`, including single-row messages. Inline images split a source block into offset-keyed parts, so nothing can come to rely on the two being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its diff --git a/docs/native-text-selection.md b/docs/native-text-selection.md index 9e9a64776c..e59ea89cde 100644 --- a/docs/native-text-selection.md +++ b/docs/native-text-selection.md @@ -2,7 +2,7 @@ Assistant prose uses `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view, so separate paragraph rows prevent dragging a selection between them. -Keep adjacent paragraphs, headings, lists and horizontal rules in prose runs. Code, tables, images and HTML-like content need Paseo's existing renderers. Preserve whole containers when splitting around an image would lose formatting or list context. Reference definitions retain their original block scope; grouping text for selection must not change which references resolve. +Keep completed adjacent paragraphs, headings, lists and horizontal rules in prose runs. The growing last source block uses Paseo’s existing block renderer and streaming parser. It joins the preceding run when another block starts or the turn ends, retaining the run’s id. Selection across that paragraph becomes available at completion. Code, tables, images and HTML-like content need Paseo's existing renderers. Preserve whole containers when splitting around an image would lose formatting or list context. Reference definitions retain their original block scope; grouping text for selection must not change which references resolve. Selection stops at preserved blocks, blockquotes and run boundaries required by the 32,000-character render cap. Consecutive paragraphs inside a quote share its text segment; nested quotes and embedded blocks introduce further library segments. Web keeps its existing renderer and selection behavior. @@ -23,7 +23,7 @@ Checkbox toggling is disabled because changes would not persist. Admonitions add - Native h1/h2 lose their 1px bottom borders because the library has no heading-border API. H6 keeps uppercase plain text but loses its 0.5 letter spacing; code and link destinations keep their source casing. - Android heading spans override inline code's font, size and color. iOS retains the code style; its system-monospace alias needs mapping at the library boundary. Web headings are unchanged. - Published 1.1.1 cannot embed Paseo's custom React link pills or hover tooltips in its text view. Links retain Paseo's press handling. -- Streaming updates replace attributed text. The library does not retain selections made while the selected run is changing. +- Adding a completed block replaces the run’s attributed text. The library does not retain selections made while that run changes. The growing block never goes through native prose preparation. Keep the package's math, highlighting, tree-sitter and video engines disabled. Paseo owns those renderers; duplicate native engines add binary size without serving this surface. Rebuild the dev client after changing engine configuration. diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index e1452ad9cf..3376ffe8c6 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -146,7 +146,9 @@ describe("native prose runs", () => { for (const text of ["First.\n\nBefore ![shot](shots/app.png) after.", "\n\nNext.", " More."]) { combined += text; state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) }); - const live = rows(present({ ...presentationOptions, ...state, transform: undefined })); + const live = rows( + present({ ...presentationOptions, ...state, isTurnActive: false, transform: undefined }), + ); const history = createStreamPresentation({ markdown: "prose-runs" })({ ...presentationOptions, tail: hydrateStreamState([{ event: assistant(combined), timestamp: new Date(1000) }]), @@ -156,7 +158,13 @@ describe("native prose runs", () => { expect(live).toEqual(history.tail); } }); - it("keeps 600 short-paragraph appends near blocks mode", { retry: 2 }, () => { + it.each([ + ["short paragraphs", "A short paragraph.\n\n".repeat(3).slice(0, 50)], + [ + "one growing paragraph", + "Use `foo` with https://example.com/path and `bar`. ".repeat(2).slice(0, 50), + ], + ])("keeps 600 appends near blocks mode: %s", { retry: 2 }, (_name, chunk) => { function measure(markdown: "blocks" | "prose-runs") { const present = createStreamPresentation({ markdown }); let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; @@ -164,7 +172,7 @@ describe("native prose runs", () => { for (let i = 0; i < 600; i++) { state = applyStreamEvent({ ...state, - event: assistant("A short paragraph.\n\n".repeat(3).slice(0, 50)), + event: assistant(chunk), timestamp: new Date(1000), }); present({ ...presentationOptions, ...state, transform: undefined }); @@ -201,7 +209,12 @@ describe("native prose runs", () => { state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) }); present({ ...presentationOptions, ...state, transform: undefined }); } - const live = present({ ...presentationOptions, ...state, transform: undefined }); + const live = present({ + ...presentationOptions, + ...state, + isTurnActive: false, + transform: undefined, + }); const history = createStreamPresentation({ markdown })({ ...presentationOptions, tail: hydrateStreamState([ @@ -242,7 +255,12 @@ describe("native prose runs", () => { state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) }); present({ ...presentationOptions, ...state, transform: undefined }); } - const live = present({ ...presentationOptions, ...state, transform: undefined }); + const live = present({ + ...presentationOptions, + ...state, + isTurnActive: false, + transform: undefined, + }); expect([...live.tail, ...live.head]).toMatchObject([ { id: "message-1:run:0", text: first }, { id: "message-1:run:1", text: second }, @@ -315,23 +333,65 @@ describe("native prose runs", () => { ]); }); - it("grows the live run and retains promoted rows", () => { + it.each(["> Quoted text.", "- List item", "### Heading", "Plain paragraph."])( + "classifies the final block at turn completion: %s", + (text) => { + const present = createStreamPresentation({ markdown: "prose-runs" }); + const head = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]); + expect( + present({ ...presentationOptions, tail: [], head, transform: undefined }).head, + ).toMatchObject([{ markdownKind: "block" }]); + const completed = present({ + ...presentationOptions, + isTurnActive: false, + tail: [], + head, + transform: undefined, + }); + const history = createStreamPresentation({ markdown: "prose-runs" })({ + ...presentationOptions, + tail: head, + head: [], + transform: undefined, + }); + expect(rows(completed)).toEqual(history.tail); + }, + ); + it("keeps the growing block separate and merges completed blocks into a stable run", () => { const present = createStreamPresentation({ markdown: "prose-runs" }); let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; function send(text: string) { state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) }); return present({ ...presentationOptions, ...state, transform: undefined }); } - const first = send("First."); + expect(send("First.").head).toMatchObject([{ id: "message-1:block:0", markdownKind: "block" }]); const second = send("\n\nSecond."); - expect(second.tail).toEqual([]); - expect(second.head).toMatchObject([{ id: first.head[0]!.id, text: "First.\n\nSecond." }]); - const boundary = send("\n\n```ts\na\n```"); - expect(boundary.tail[0]).toBe(second.head[0]); - const last = send("\n\nAfter."); - expect(last.tail[0]).toBe(second.head[0]); - expect(last.tail[1]).toBe(boundary.head[0]); - expect(last.head).toMatchObject([{ id: "message-1:run:3", text: "After." }]); + expect(second.tail).toMatchObject([ + { id: "message-1:run:0", text: "First.", markdownKind: "prose" }, + ]); + expect(second.head).toMatchObject([ + { id: "message-1:block:1", text: "Second.", markdownKind: "block" }, + ]); + const growing = send(" More."); + expect(growing.tail[0]).toBe(second.tail[0]); + const third = send("\n\nThird."); + expect(third.tail).toMatchObject([{ id: "message-1:run:0", text: "First.\n\nSecond. More." }]); + const completed = present({ + ...presentationOptions, + ...state, + isTurnActive: false, + transform: undefined, + }); + const history = createStreamPresentation({ markdown: "prose-runs" })({ + ...presentationOptions, + tail: [...state.tail, ...state.head], + head: [], + transform: undefined, + }); + expect(rows(completed)).toEqual(history.tail); + expect(rows(completed)).toMatchObject([ + { id: "message-1:run:0", text: "First.\n\nSecond. More.\n\nThird." }, + ]); }); it("keeps consecutive paragraphs in one selectable row", () => { diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 4fbd4a72cb..390e52c7f7 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -59,6 +59,7 @@ export function createStreamPresentation({ markdown = "blocks", }: { markdown?: "blocks" | "prose-runs" } = {}) { const runsBySource = new WeakMap(); + const growingRunsBySource = new WeakMap(); const userMessageCache = new WeakMap(); function presentUserMessage(item: UserMessageItem): UserMessageItem { @@ -145,16 +146,17 @@ export function createStreamPresentation({ return blocks; } - function presentRows(item: StreamItem): StreamItem[] { + function presentRows(item: StreamItem, growing = false): StreamItem[] { const blocks = nativeBlocks(item); if (markdown === "blocks" || item.kind !== "assistant_message") return blocks; - const cached = runsBySource.get(item); + const cache = growing ? growingRunsBySource : runsBySource; + const cached = cache.get(item); if (cached) return cached; const previousSource = liveSources.get(item.id); const previousBlocks = previousSource && blocksBySource.get(previousSource); // nativeBlocks emits only assistant blocks for an assistant source. - const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks); - runsBySource.set(item, runs); + const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks, growing); + cache.set(item, runs); return runs; } @@ -165,7 +167,7 @@ export function createStreamPresentation({ // `blocksBySource` keeps both the live block identities and the split cost // from being paid again when only the tail's array identity changed. historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( - presentRows, + (item) => presentRows(item), ); historySource = input.tail; historyTransform = input.transform; @@ -174,7 +176,7 @@ export function createStreamPresentation({ const promoted: StreamItem[] = []; const nextLiveSources = new Map(); for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) { - const blocks = presentRows(item); + const blocks = presentRows(item, input.isTurnActive); if (item.kind === "assistant_message") nextLiveSources.set(item.id, item); if (item.kind === "assistant_message" && blocks.length > 1) { promoted.push(...blocks.slice(0, -1)); diff --git a/packages/app/src/components/markdown-prose/prepare.test.ts b/packages/app/src/components/markdown-prose/prepare.test.ts index bbd00e14aa..dc5d055b79 100644 --- a/packages/app/src/components/markdown-prose/prepare.test.ts +++ b/packages/app/src/components/markdown-prose/prepare.test.ts @@ -20,13 +20,6 @@ describe("source-preserving prose", () => { ])("uses parser-owned link ranges: %s", (source, expected) => { expect(prepareProse([source], noFiles)).toBe(expected); }); - it.each([ - ["Use ~", "Use "], - ["Use ~/", "Use \\~/"], - ["Use ~/.config", "Use \\~/.config"], - ])("keeps provisional tilde escapes attached: %s", (source, expected) => { - expect(prepareProse([source], noFiles, true)).toBe(expected); - }); it.each([ ["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."], ["~/a ~/b", "\\~/a \\~/b"], @@ -35,7 +28,6 @@ describe("source-preserving prose", () => { ["`~~` and `~/a ~/b`", "`~~` and `~/a ~/b`"], ])("keeps single tildes literal: %s", (source, expected) => { expect(prepareProse([source], noFiles)).toBe(expected); - expect(prepareProse([source], noFiles, true)).toBe(expected); }); it.each([ ["https://example.com/a&b", "[https://example.com/a&b]()"], @@ -99,9 +91,9 @@ describe("source-preserving prose", () => { candidates.push(text); return false; }); - prepare(["First `one`.", "Next `two`.", "Last `three`."], true, Infinity); + prepare(["First `one`.", "Next `two`.", "Last `three`."], Infinity); candidates.length = 0; - prepare(["First `one`.", "Next `two`.", "Last `three`. More."], true, Infinity); + prepare(["First `one`.", "Next `two`.", "Last `three`. More."], Infinity); expect(candidates).toEqual(["three"]); }); it("bounds preparation before parsing a capped block", () => { @@ -111,7 +103,7 @@ describe("source-preserving prose", () => { return false; }); const source = "Use `foo` here. ".repeat(70_000); - prepare([source], false, 32_000); + prepare([source], 32_000); expect(candidates).toBeLessThan(2200); }); it("decodes entities outside code", () => { @@ -150,25 +142,6 @@ describe("source-preserving prose", () => { for (let i = 0; i < 5; i++) expect(prepareProse([text], noFiles)).toBe(text); expect((performance.now() - start) / 5).toBeLessThan(50); }); - it("reuses prepared prefixes while completing the visible inline tail", () => { - const prepare = createProsePreparer(noFiles); - const blocks = ["First & paragraph.", "Use `foo` and **bold** here. ".repeat(30)]; - const source = blocks.join("\n\n"); - prepare(blocks, true, source.length); - for (let length = 0; length < source.length; length += 7) { - expect(prepare(blocks, true, length)).toBe(prepareProse(blocks, noFiles, true, length)); - } - }); - it.each([ - ["A ||literal|| and escaped \\* text.", 2, "A "], - ["Use `README.md` and example.com. Later.", 4, "Use "], - ["[unresolved label] later.", 12, "unresolved "], - ["[unresolved label] later.", 19, "unresolved label "], - ] as const)("keeps cached reveal boundaries literal: %s", (source, length, expected) => { - const prepare = createProsePreparer((text) => text === "README.md"); - prepare([source], true, source.length); - expect(prepare([source], true, length)).toBe(expected); - }); it("changes only prose pipes", () => { expect(prepareProse(["Keep ||literal|| and `a || b` and [a|b](https://x/a|b)."], noFiles)).toBe( "Keep \\|\\|literal\\|\\| and `a || b` and [a\\|b](https://x/a|b).", @@ -194,18 +167,6 @@ describe("source-preserving prose", () => { ), ).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &"); }); - it.each([ - ["Before **bold", "Before **bold**"], - ["Before `code", "Before `code`"], - ["Before [label](https://exa", "Before label"], - ["### A **heading", "### A **heading**"], - ["- First\n- **Second", "- First\n- **Second**"], - ["> First\n> **Second", "> First\n> **Second**"], - ["Before **", "Before "], - ["Before ~~strike", "Before ~~strike~~"], - ])("minimally completes the streaming tail: %s", (source, expected) => { - expect(prepareProse([source], noFiles, true)).toBe(expected); - }); it("prepares source blocks independently, even beside a reference definition", () => { const blocks = ["![shot][pic]", "Other paragraph.\n\n[pic]: shots/app.png"]; expect(prepareProse(blocks, noFiles)).toBe( diff --git a/packages/app/src/components/markdown-prose/prepare.ts b/packages/app/src/components/markdown-prose/prepare.ts index 3e867dd395..60a5faa8a4 100644 --- a/packages/app/src/components/markdown-prose/prepare.ts +++ b/packages/app/src/components/markdown-prose/prepare.ts @@ -2,8 +2,7 @@ import type { AssistantFileLinkSource } from "@/assistant-file-links"; import type Token from "markdown-it/lib/token.mjs"; import { type InlineSpan, type InlineTrace, createSourceParser } from "./source-inline"; -const settled = createSourceParser(false); -const provisional = createSourceParser(true); +const settled = createSourceParser(); const INLINE_CODE_SCHEME = "paseo-inline-code:"; const FILE_LINK_SCHEME = "paseo-file-link:"; @@ -58,83 +57,34 @@ function applyEdits(source: string, edits: Edit[]): string { return result + source.slice(cursor); } -/** Source blocks are presentation's canonical inputs, never reconstructed from a joined run. */ +/** Each completed source block retains its own reference scope. */ export function prepareProse( blocks: readonly string[], canResolveCode: (text: string) => boolean, - streaming = false, visibleLength = Infinity, ): string { - const visible: string[] = []; - let remaining = visibleLength; - for (const block of blocks) { - if (remaining < 0) break; - visible.push(block.slice(0, remaining)); - remaining -= block.length + 2; - } - return visible - .map((block, index) => - prepareBlock(block, canResolveCode, streaming && index === visible.length - 1), - ) - .join("\n\n"); + return createProsePreparer(canResolveCode)(blocks, visibleLength); } -interface PreparedBlock { - text: string; - cuts: number[]; - offsets: number[]; -} - -/** One cache per mounted prose row. Reveal reuses the prepared prefix and parses only its open tail. */ +/** One cache per prose row; joining a completed block does not reparse earlier blocks. */ export function createProsePreparer(canResolveCode: (text: string) => boolean) { - let cache = new Map(); - return (blocks: readonly string[], streaming: boolean, visibleLength: number): string => { - const retained = new Map(); + let cache = new Map(); + return (blocks: readonly string[], visibleLength = Infinity): string => { + const retained = new Map(); const result: string[] = []; let remaining = visibleLength; - for (const [index, block] of blocks.entries()) { + for (const block of blocks) { if (remaining < 0) break; - const live = streaming && (index === blocks.length - 1 || remaining < block.length); - const key = `${live}:${block}`; const source = block.slice(0, remaining); - const cached = cache.get(key); - const entry = - cached && cached.source.length >= source.length - ? cached - : { source, prepared: compileBlock(source, canResolveCode, live) }; - retained.set(key, entry); - result.push( - revealBlock(entry.source, entry.prepared, source.length, canResolveCode, streaming), - ); + const prepared = cache.get(source) ?? prepareBlock(source, canResolveCode); + retained.set(source, prepared); + result.push(prepared); remaining -= block.length + 2; } cache = retained; return result.join("\n\n"); }; } -function revealBlock( - source: string, - prepared: PreparedBlock, - length: number, - canResolveCode: (text: string) => boolean, - streaming: boolean, -): string { - if (length >= source.length) return prepared.text; - // Checkpoints are outside inline containers, at whitespace in a plain paragraph. - let low = 0; - let high = prepared.cuts.length; - while (low < high) { - const mid = (low + high) >>> 1; - if (prepared.cuts[mid]! <= length) low = mid + 1; - else high = mid; - } - const index = low - 1; - if (index < 0) return prepareBlock(source.slice(0, length), canResolveCode, streaming); - const cut = prepared.cuts[index]!; - // Retain paragraph context if the tail happens to begin with a block marker. - const tail = prepareBlock(`x ${source.slice(cut, length)}`, canResolveCode, streaming).slice(2); - return prepared.text.slice(0, prepared.offsets[index]) + tail; -} interface InlinePreparation { source: string; @@ -142,7 +92,6 @@ interface InlinePreparation { trace: InlineTrace; parser: typeof settled.parser; env: object; - streaming: boolean; uppercase: boolean; canResolveCode: (text: string) => boolean; protectedRanges: Uint8Array; @@ -166,29 +115,19 @@ function codeUrl(context: InlinePreparation, content: string): string | null { const match = matches?.length === 1 ? matches[0] : undefined; return match?.index === 0 && match.lastIndex === content.length ? match.url : null; } -function prepareCode(context: InlinePreparation, span: InlineSpan, raw: string, code: Token) { +function prepareCode(context: InlinePreparation, span: InlineSpan, code: Token) { protect(context.protectedRanges, span); protect(context.referenceRanges, span); - const complete = raw.length >= code.markup.length * 2 && raw.endsWith(code.markup); - const closing = complete ? "" : code.markup.slice(raw.match(/`+$/)?.[0].length ?? 0); const url = codeUrl(context, code.content); if (!span.insideLink && url) { insert(context, span.start, "["); - insert(context, span.end, `${closing}](<${url}>)`); - } else if (closing) insert(context, span.end, closing); + insert(context, span.end, `](<${url}>)`); + } } function labelEnd(context: InlinePreparation, source: string, start: number): number { const state = new context.parser.inline.State(source, context.parser, context.env, []); return context.parser.helpers.parseLinkLabel(state, start, false); } -function prepareUnfinishedLink(context: InlinePreparation, span: InlineSpan, raw: string) { - const start = raw.startsWith("![") ? 1 : 0; - const end = labelEnd(context, raw, start); - context.edits.push({ start: span.start, end: span.start + start + 1, text: "" }); - if (start === 1) context.edits.push({ start: span.start + 2, end: span.end, text: "" }); - else if (end !== -1) context.edits.push({ start: span.start + end, end: span.end, text: "" }); - protect(context.referenceRanges, span); -} function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: string, link: Token) { const end = labelEnd(context, raw, 0); protect(context.protectedRanges, { start: span.start + end, end: span.end }); @@ -275,22 +214,10 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) { } else if (link && raw.startsWith("[")) { preserveLinkScope(context, span, raw, link); } else if (code && raw.startsWith("`")) { - prepareCode(context, span, raw, code); - } else if ( - context.streaming && - !contains(context.referenceRanges, span.start) && - (raw.startsWith("[") || raw.startsWith("![")) - ) { - prepareUnfinishedLink(context, span, raw); - } else if ( - context.streaming && - span.tokens.length === 0 && - /^(?:\*{1,3}|_{1,3}|~{1,2}|`+)$/.test(raw) - ) { - context.edits.push({ start: span.start, end: span.end, text: "" }); - protect(context.protectedRanges, span); + prepareCode(context, span, code); } } + function preserveUnresolvedReferences(context: InlinePreparation) { // Protect every unresolved label, regardless of CommonMark's case/whitespace // normalization. Escapes inside a label do not make its opening bracket safe. @@ -421,19 +348,7 @@ function sourcePositions( } return positions; } -function prepareBlock( - text: string, - canResolveCode: (text: string) => boolean, - streaming: boolean, -): string { - return compileBlock(text, canResolveCode, streaming).text; -} -function compileBlock( - text: string, - canResolveCode: (text: string) => boolean, - streaming: boolean, -): PreparedBlock { - const normalized = !/[\r\t]/.test(text); +function prepareBlock(text: string, canResolveCode: (text: string) => boolean): string { text = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\ufffd"); text = text.replace(/^[ \t>]*/gm, (prefix) => { let expanded = ""; @@ -441,7 +356,7 @@ function compileBlock( expanded += char === "\t" ? " ".repeat(4 - (expanded.length % 4)) : char; return expanded; }); - const { parser, traces } = streaming ? provisional : settled; + const { parser, traces } = settled; const env = {}; const tokens = parser.parse(text, env); const lines = text.split("\n"); @@ -452,17 +367,9 @@ function compileBlock( offset += line.length + 1; } const edits: Edit[] = []; - let cuts: number[] = []; for (const [index, token] of tokens.entries()) { if (token.type !== "inline" || !token.map) continue; const trace = traces.get(token)!; - if ( - normalized && - text === token.content && - tokens.length === 3 && - tokens[0]!.type === "paragraph_open" - ) - cuts = paragraphCuts(token.content, trace); const heading = tokens[index - 1]; const context: InlinePreparation = { source: token.content, @@ -470,7 +377,6 @@ function compileBlock( trace, parser, env, - streaming, uppercase: heading?.type === "heading_open" && heading.tag === "h6", canResolveCode, protectedRanges: new Uint8Array(token.content.length), @@ -478,59 +384,17 @@ function compileBlock( task: tokens[index - 2]?.type === "list_item_open", edits: [], }; - // Link/code ownership is established before either references or provisional - // brackets can be rewritten. Linkify uses the parser's emphasis-split text tokens. + // Link/code ownership is established before references can be rewritten. Linkify uses the parser's emphasis-split text tokens. for (const span of trace.spans) if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type))) protect(context.referenceRanges, span); prepareLinkifiedLinks(context); for (const span of trace.spans) prepareSpan(context, span); - if (trace.trim) { - const tail = { start: token.content.length - trace.trim, end: token.content.length }; - context.edits.push({ ...tail, text: "" }); - protect(context.protectedRanges, tail); - } preserveUnresolvedReferences(context); prepareText(context); - if (trace.suffix) insert(context, token.content.length, trace.suffix); const positions = sourcePositions(token, heading, lines, lineOffsets); for (const edit of context.edits) edits.push({ ...edit, start: positions[edit.start]!, end: positions[edit.end]! }); } - const rendered = applyEdits(text, edits); - return { text: rendered, cuts, offsets: preparedOffsets(cuts, edits) }; -} -function preparedOffsets(cuts: number[], edits: Edit[]): number[] { - const offsets: number[] = []; - let editIndex = 0; - let delta = 0; - for (const cut of cuts) { - while ( - editIndex < edits.length && - edits[editIndex]!.end <= cut && - edits[editIndex]!.start < cut - ) { - const edit = edits[editIndex++]!; - delta += edit.text.length - (edit.end - edit.start); - } - offsets.push(cut + delta); - } - return offsets; -} -function paragraphCuts(source: string, trace: InlineTrace): number[] { - const cuts: number[] = []; - let depth = 0; - // A bracket can remain a provisional reference/link even after its closing bracket. - // Keep that suffix together until the block settles. - const bracket = source.indexOf("["); - const stableEnd = bracket < 0 ? source.length : bracket; - for (const span of trace.spans) { - if (span.insideLink) continue; - if (depth === 0 && span.tokens.length === 0 && source[span.start] !== "&") { - for (let i = span.start; i < span.end; i++) - if (i < stableEnd && source[i] === " ") cuts.push(i + 1); - } - for (const token of span.tokens) depth += token.nesting; - } - return cuts; + return applyEdits(text, edits); } diff --git a/packages/app/src/components/markdown-prose/props.ts b/packages/app/src/components/markdown-prose/props.ts index f828fe756b..19f2d5092e 100644 --- a/packages/app/src/components/markdown-prose/props.ts +++ b/packages/app/src/components/markdown-prose/props.ts @@ -3,7 +3,6 @@ import type { ReactNode } from "react"; export interface MarkdownProseProps { text: string; sourceBlocks: readonly string[]; - streaming: boolean; kind: "prose" | "quote" | "block"; onLinkPress: (url: string) => boolean; children: ReactNode; diff --git a/packages/app/src/components/markdown-prose/renderer.native.tsx b/packages/app/src/components/markdown-prose/renderer.native.tsx index 234ec593a0..91a2511a83 100644 --- a/packages/app/src/components/markdown-prose/renderer.native.tsx +++ b/packages/app/src/components/markdown-prose/renderer.native.tsx @@ -96,7 +96,6 @@ const PROSE_CONTAINER = { marginBottom: -12 }; const NativeProse = memo(function NativeProse({ text, sourceBlocks, - streaming, kind, onLinkPress, }: MarkdownProseProps) { @@ -106,8 +105,8 @@ const NativeProse = memo(function NativeProse({ ); const prepare = useMemo(() => createProsePreparer(canResolveCode), [canResolveCode]); const markdown = useMemo( - () => prepare(sourceBlocks, streaming, text.length), - [text.length, sourceBlocks, prepare, streaming], + () => prepare(sourceBlocks, text.length), + [text.length, sourceBlocks, prepare], ); const handleLinkPress = useStableEvent(({ url }: { url: string }) => { handleProseLinkPress(url, (source) => actions.open(source, "preferred"), onLinkPress); diff --git a/packages/app/src/components/markdown-prose/runs.ts b/packages/app/src/components/markdown-prose/runs.ts index 655ad34180..a58472afdf 100644 --- a/packages/app/src/components/markdown-prose/runs.ts +++ b/packages/app/src/components/markdown-prose/runs.ts @@ -66,21 +66,25 @@ function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem } interface GroupedRuns { + growing: boolean; rows: AssistantMessageItem[]; starts: number[]; ends: number[]; } const runsByBlocks = new WeakMap(); +const growingRunsByBlocks = new WeakMap(); /** Rebuild only the run containing the changed source block; completed runs retain identity. */ export function groupMarkdownProse( blocks: readonly AssistantMessageItem[], previousBlocks?: readonly AssistantMessageItem[], + growing = false, ): AssistantMessageItem[] { - const cached = runsByBlocks.get(blocks); + const cache = growing ? growingRunsByBlocks : runsByBlocks; + const cached = cache.get(blocks); if (cached) return cached.rows; - const previous = previousBlocks && runsByBlocks.get(previousBlocks); - const retained = retainedRunCount(blocks, previousBlocks, previous); + const previous = previousGroups(previousBlocks); + const retained = retainedRunCount(blocks, previousBlocks, previous, growing); const start = previous?.starts[retained] ?? 0; const rows = previous?.rows.slice(0, retained) ?? []; const starts = previous?.starts.slice(0, retained) ?? []; @@ -105,14 +109,13 @@ export function groupMarkdownProse( length = 0; } for (let index = start; index < blocks.length; index++) { - for (const part of splitBlock(blocks[index]!)) { - if ( - parts.length && - (part.markdownKind !== "prose" || - parts[0]!.markdownKind !== "prose" || - length + 2 + part.text.length > ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) - ) - flush(); + const block = blocks[index]!; + const partsForBlock = + growing && index === blocks.length - 1 + ? [{ ...block, markdownKind: "block" as const }] + : splitBlock(block); + for (const part of partsForBlock) { + if (parts.length && !canJoinRun(parts[0]!, part, length)) flush(); if (!parts.length) runStart = index; length += (parts.length ? 2 : 0) + part.text.length; parts.push(part); @@ -121,7 +124,7 @@ export function groupMarkdownProse( } flush(); reuseUnchangedRows(blocks, previousBlocks, previous, rows, starts, ends, retained); - runsByBlocks.set(blocks, { rows, starts, ends }); + cache.set(blocks, { rows, starts, ends, growing }); return rows; } @@ -129,15 +132,20 @@ function retainedRunCount( blocks: readonly AssistantMessageItem[], previousBlocks: readonly AssistantMessageItem[] | undefined, previous: GroupedRuns | undefined, + growing: boolean, ): number { if (!previous || !previousBlocks) return 0; let shared = 0; while (shared < blocks.length && blocks[shared] === previousBlocks[shared]) shared++; + if (previous.growing && (!growing || blocks.length !== previousBlocks.length)) + shared = Math.min(shared, previousBlocks.length - 1); let retained = 0; - // Revisit the boundary run because newly appended prose can join it. - while (retained + 1 < previous.rows.length && previous.starts[retained + 1]! < shared) retained++; - // Several image parts can originate from one source block. Rebuild them together. - while (retained > 0 && previous.ends[retained - 1]! >= previous.starts[retained]!) retained--; + while (retained < previous.rows.length && previous.ends[retained]! < shared) retained++; + // Only completed blocks can merge with the preceding run. The live tail is a + // hard boundary, so appending to it never rebuilds completed prose. + if (!(growing && shared === blocks.length - 1)) retained = Math.max(0, retained - 1); + while (retained > 0 && previous.ends[retained - 1]! >= (previous.starts[retained] ?? shared)) + retained--; return retained; } function reuseUnchangedRows( @@ -153,7 +161,8 @@ function reuseUnchangedRows( for (let index = retained; index < rows.length; index++) { const old = previous.rows[index]; const row = rows[index]!; - if (old?.id !== row.id || old.text !== row.text) continue; + if (old?.id !== row.id || old.text !== row.text || old.markdownKind !== row.markdownKind) + continue; const end = ends[index]!; let identical = true; for (let block = starts[index]!; block <= end; block++) @@ -165,3 +174,21 @@ function reuseUnchangedRows( rows[index] = old; } } + +function canJoinRun( + first: AssistantMessageItem, + next: AssistantMessageItem, + length: number, +): boolean { + return ( + first.markdownKind === "prose" && + next.markdownKind === "prose" && + length + 2 + next.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + ); +} + +function previousGroups( + blocks: readonly AssistantMessageItem[] | undefined, +): GroupedRuns | undefined { + return blocks && (growingRunsByBlocks.get(blocks) ?? runsByBlocks.get(blocks)); +} diff --git a/packages/app/src/components/markdown-prose/source-inline.ts b/packages/app/src/components/markdown-prose/source-inline.ts index 26e5440174..f311fbfee9 100644 --- a/packages/app/src/components/markdown-prose/source-inline.ts +++ b/packages/app/src/components/markdown-prose/source-inline.ts @@ -10,19 +10,17 @@ export interface InlineSpan { export interface InlineTrace { spans: InlineSpan[]; linkifyTexts: string[]; - suffix: string; - trim: number; } const TRACE = Symbol("prose source positions"); /** Observe the actual inline rules, including nested labels, without reprinting tokens. */ -export function createSourceParser(streaming: boolean) { - const parser = createAssistantMarkdownParser({ streaming }); +export function createSourceParser() { + const parser = createAssistantMarkdownParser(); const traces = new WeakMap(); const inlineTraces = new WeakMap(); const parseInline = parser.inline.parse.bind(parser.inline); parser.inline.parse = (source, md, env, output) => { - const trace: InlineTrace = { spans: [], linkifyTexts: [], suffix: "", trim: 0 }; + const trace: InlineTrace = { spans: [], linkifyTexts: [] }; parseInline(source, md, { ...env, [TRACE]: trace }, output); inlineTraces.set(output, trace); }; @@ -45,30 +43,6 @@ export function createSourceParser(streaming: boolean) { } return false; }); - if (streaming) { - const counts = new WeakMap< - object, - { count: number; last: Token | undefined; content: string } - >(); - parser.inline.ruler2.before("streaming_emphasis", "prose_before_completion", (state) => { - counts.set(state, { - count: state.tokens.length, - last: state.tokens.at(-1), - content: state.tokens.at(-1)?.content ?? "", - }); - return true; - }); - parser.inline.ruler2.after("streaming_emphasis", "prose_after_completion", (state) => { - const before = counts.get(state)!; - const trace: InlineTrace = state.env[TRACE]; - trace.suffix = state.tokens - .slice(before.count) - .map((token) => token.content) - .join(""); - trace.trim = before.content.length - (before.last?.content.length ?? 0); - return true; - }); - } parser.core.ruler.after("inline", "prose_inline_traces", (state) => { for (const token of state.tokens) { if (token.type !== "inline" || !token.children) continue; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 96f6ce0e9a..722f6e7b31 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1998,7 +1998,6 @@ export const AssistantMessage = memo(function AssistantMessage({ return (