From 6ca128eec8eeb14bfddde298180f2a39f92bde33 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:48:53 +0100 Subject: [PATCH] perf(mobile): convert only the changed block of a streaming assistant message A streamed reply re-ran the whole Markdown-to-native conversion on every update. While a message is streaming, split its text at blank lines that close every open block, keep the previous update's converted blocks, and convert only the blocks that changed. Finished text, every other consumer and text with syntax that ties blocks together keep the whole-text conversion. --- .../src/features/threads/ThreadFeed.tsx | 5 + apps/mobile/src/lib/markdownDocument.ts | 43 ++- .../src/lib/markdownStreamBlocks.test.ts | 306 ++++++++++++++++++ apps/mobile/src/lib/markdownStreamBlocks.ts | 234 ++++++++++++++ .../src/native/EnrichedMarkdownText.tsx | 40 ++- .../native/SelectableMarkdownText.types.ts | 2 + 6 files changed, 597 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/lib/markdownStreamBlocks.test.ts create mode 100644 apps/mobile/src/lib/markdownStreamBlocks.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1abfb6061a6bb..adc6fbb80bc86 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1191,6 +1191,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; + readonly streaming?: boolean | undefined; }) { const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown); @@ -1208,6 +1209,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { onUseArtifactTemplate={props.onUseArtifactTemplate} renderImage={props.renderImage} skills={props.skills} + streaming={props.streaming} /> ); }); @@ -1220,6 +1222,7 @@ function AssistantMarkdownBlocks(props: { readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; + readonly streaming?: boolean | undefined; }) { const blocks = useMemo( () => @@ -1262,6 +1265,7 @@ function AssistantMarkdownBlocks(props: { textStyle={props.markdownStyles.nativeTextStyle} {...props.linkHandlers} renderImage={props.renderImage} + streaming={props.streaming} /> ); }); @@ -2266,6 +2270,7 @@ function renderFeedEntry( onUseArtifactTemplate={props.onUseArtifactTemplate} renderImage={props.renderMarkdownImage} skills={props.skills} + streaming={message.streaming} /> ) : null} diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 6e5bc86cf771d..0274b3454d90b 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -287,25 +287,35 @@ export function markdownWithHtml(markdown: string): string { // Start only at the beginning of a backtick run to avoid retrying every suffix. const INLINE_CODE_SPAN = /(? + /\.png$/.test(href) ? `${standalone ? "S" : "N"}:${href}` : null; +const owners = [ + { name: "plain", owner: undefined }, + { name: "embed", owner: label }, +] as const; + +const sameRender = (a: NativeMarkdownRender, b: NativeMarkdownRender) => + a.markdown === b.markdown && [...a.fileHrefs].toString() === [...b.fileHrefs].toString(); + +/** What the component does while a reply streams. */ +function stream( + text: string, + owner: typeof label | undefined, + cache = createNativeMarkdownBlockCache(), + pipeline: NativeMarkdownPipeline = nativeMarkdownPipeline(owner), +): NativeMarkdownRender | null { + return convertStreamingMarkdown(text, owner, cache, true, pipeline, 0); +} + +// Every prefix of `text`, through one cache, must convert like the whole text (or be refused). +function expectPrefixesMatch(name: string, text: string) { + for (const { owner } of owners) { + const cache = createNativeMarkdownBlockCache(); + for (let end = 0; end <= text.length; end += 1) { + const prefix = text.slice(0, end); + const streamed = stream(prefix, owner, cache); + if (streamed === null) continue; + const expected = convertNativeMarkdown(prefix, owner); + expect(streamed.markdown, `${name}@${end}`).toBe(expected.markdown); + expect([...streamed.fileHrefs], `${name}@${end}`).toEqual([...expected.fileHrefs]); + } + } +} + +const fence = + "```ts\nexport function f(x: Array) {\n return `a/b.ts`;\n\n // blank\n}\n```"; +const samples: Record = { + prose: "Intro with `src/a.ts:12` and Array.\n\nSecond paragraph.\n\nThird.", + fences: `Before\n\n${fence}\n\nAfter \`src/b.ts\`\n\n\`\`\`\nunclosed
\n\nstill code`, + list: "- one `src/a.ts`\n- two\n\n- loose\n\n continued\n\n ```sh\n echo \n\n more\n ```\n- last\n\nend", + quote: "> quoted `x/y.ts`\n>\n> more\n\n> next quote\n\ntail", + tables: "Text\n| a | b |\n| - | - |\n| `src/a\\|b.ts` | 2 |\n\nmid\n\n| c |\n| - |\n| d |", + html: 'Use bold and link
x\n\nArray and 1 < 2\n\nend', + images: "![a](pic.png) and ![b](https://e.com/z.jpg)\n\n![c](d.png)\n\ntext", + windows: + String.raw`[w](C:\work\.t3\f.md)` + "\n\nplain `src/q.ts`\n\n" + String.raw`[v]()`, + setext: "Title\n===\n\nbody\n\n---\n\nmore", + indented: "para\n\n code \n\n more\n\nafter", +}; + +// Each of these made a block-by-block conversion differ from the whole-text one in an earlier +// design, so they are kept as named cases. +const pitfalls: Record = { + tripleBacktickProse: + "To open a fence, type ``` on its own line; see `src/a.ts` for details.\n\nAlso check `src/b.ts`.\n\nDone.", + literalBacktick: + "Escape a backtick with `` ` `` as in `src/a.ts`.\n\nSee also `src/b.ts`.\n\nDone.", + wrappedCodeSpan: "Run `vp test\nrun` on `src/a.ts`.\n\nSee also `src/b.ts`.\n\nDone.", + brOpensFence: "text
```ts\nconst a = 1;\n\nnext `src/a.ts`\n\nlast", + pOpensFence: "x

```\n\nfoo `src/a.ts`\n\nlast", + removedTagJoinsBackticks: "``