diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e3e9e97e78510..174a7206dd985 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1147,6 +1147,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 models = useModelMentions(props.environmentId, props.markdown); const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); @@ -1166,6 +1167,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { renderImage={props.renderImage} skills={props.skills} models={models} + streaming={props.streaming} /> ); }); @@ -1179,6 +1181,7 @@ function AssistantMarkdownBlocks(props: { readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; readonly models: ReturnType; + readonly streaming?: boolean | undefined; }) { const blocks = useMemo( () => @@ -1222,6 +1225,7 @@ function AssistantMarkdownBlocks(props: { textStyle={props.markdownStyles.nativeTextStyle} {...props.linkHandlers} renderImage={props.renderImage} + streaming={props.streaming} /> ); }); @@ -2232,6 +2236,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 23f6d5352c679..1810f6fa31f7f 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -285,25 +285,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: "``