From 75907b360bea28bdd56a4e6f69093ddba7000ddc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 04:12:42 +0200 Subject: [PATCH] fix(app): render selectable native prose with enriched markdown --- package-lock.json | 18 +++ packages/app/package.json | 7 + packages/app/src/agent-stream/view.tsx | 6 +- .../markdown-prose/index.native.tsx | 129 ++++++++++++++++++ .../src/components/markdown-prose/index.tsx | 2 + .../components/markdown-prose/index.web.tsx | 5 + .../components/markdown-prose/prepare.test.ts | 79 +++++++++++ .../src/components/markdown-prose/prepare.ts | 125 +++++++++++++++++ .../src/components/markdown-prose/props.ts | 9 ++ packages/app/src/components/message.tsx | 51 ++++--- 10 files changed, 411 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/components/markdown-prose/index.native.tsx create mode 100644 packages/app/src/components/markdown-prose/index.tsx create mode 100644 packages/app/src/components/markdown-prose/index.web.tsx create mode 100644 packages/app/src/components/markdown-prose/prepare.test.ts create mode 100644 packages/app/src/components/markdown-prose/prepare.ts create mode 100644 packages/app/src/components/markdown-prose/props.ts diff --git a/package-lock.json b/package-lock.json index c31951b1b3..41849ad6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30905,6 +30905,23 @@ "react-native": "*" } }, + "node_modules/react-native-enriched-markdown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.1.1.tgz", + "integrity": "sha512-JvbRIVy75MAoGCv8V6sSscu11/Ian588wfIgH+j64XFpf6gy5sNNkMyG087MH88jNCf3p7fTjgEAGxZMhEqL4A==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "katex": { + "optional": true + } + } + }, "node_modules/react-native-fit-image": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", @@ -36490,6 +36507,7 @@ "react-native": "0.81.5", "react-native-draggable-flatlist": "^4.0.3", "react-native-edge-to-edge": "^1.7.0", + "react-native-enriched-markdown": "1.1.1", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "1.21.12", "react-native-markdown-display": "^7.0.2", diff --git a/packages/app/package.json b/packages/app/package.json index 84af04dee4..912dabc61a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -114,6 +114,7 @@ "react-native": "0.81.5", "react-native-draggable-flatlist": "^4.0.3", "react-native-edge-to-edge": "^1.7.0", + "react-native-enriched-markdown": "1.1.1", "react-native-gesture-handler": "~2.28.0", "react-native-keyboard-controller": "1.21.12", "react-native-markdown-display": "^7.0.2", @@ -164,6 +165,12 @@ "wrangler": "^4.105.0", "ws": "^8.20.0" }, + "enriched-markdown": { + "enableMath": false, + "enableCodeHighlight": false, + "codeHighlightLanguages": [], + "enableVideo": false + }, "reanimated": { "staticFeatureFlags": { "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 1e60240be9..4032bba41a 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -535,7 +535,10 @@ const AgentStreamViewComponent = forwardRef createStreamPresentation(), []); + const presentStream = useMemo( + () => createStreamPresentation({ markdown: isWeb ? "blocks" : "prose-runs" }), + [], + ); const presentation = useMemo( () => presentStream({ @@ -726,6 +729,7 @@ const AgentStreamViewComponent = forwardRef {(renderFullContent) => ( + actions.canResolveFile({ href: content, text: content, sourceType: "inline-code" }), + ); + const markdown = useMemo( + () => prepareProse(text, canResolveCode, streaming), + [text, canResolveCode, streaming], + ); + const handleLinkPress = useStableEvent(({ url }: { url: string }) => { + const content = inlineCodeLinkContent(url); + if (content !== null) { + actions.open({ href: content, text: content, sourceType: "inline-code" }, "preferred"); + return; + } + onLinkPress(url); + }); + return ( + + ); +}); + +export function MarkdownProse(props: MarkdownProseProps) { + return props.kind === "block" ? props.children : ; +} diff --git a/packages/app/src/components/markdown-prose/index.tsx b/packages/app/src/components/markdown-prose/index.tsx new file mode 100644 index 0000000000..91eed163d9 --- /dev/null +++ b/packages/app/src/components/markdown-prose/index.tsx @@ -0,0 +1,2 @@ +// TypeScript's platform-neutral entry; Metro chooses the native or web implementation. +export { MarkdownProse } from "./index.web"; diff --git a/packages/app/src/components/markdown-prose/index.web.tsx b/packages/app/src/components/markdown-prose/index.web.tsx new file mode 100644 index 0000000000..42fd94e331 --- /dev/null +++ b/packages/app/src/components/markdown-prose/index.web.tsx @@ -0,0 +1,5 @@ +import type { MarkdownProseProps } from "./props"; + +export function MarkdownProse({ children }: MarkdownProseProps) { + return children; +} diff --git a/packages/app/src/components/markdown-prose/prepare.test.ts b/packages/app/src/components/markdown-prose/prepare.test.ts new file mode 100644 index 0000000000..6024d5b36e --- /dev/null +++ b/packages/app/src/components/markdown-prose/prepare.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; +import { inlineCodeLinkContent, prepareProse } from "./prepare"; + +const parser = createAssistantMarkdownParser(); +const noFiles = () => false; + +describe("native prose dialect", () => { + it.each([ + "Literal & and ©", + "# #", + "- first\n\tsecond", + "Blank ` ` code and ` normal ` code", + "# Heading\n\n- **First**\n- Second\n\n~~Deleted~~", + "> First paragraph\n>\n> Second paragraph", + "A [linked `path.ts`](file:///repo/path.ts)", + ])("preserves existing rendered inline content: %s", (text) => { + expect(parser.render(prepareProse(text, noFiles))).toBe(parser.render(text)); + }); + + it("escapes spoiler delimiters without changing visible text or inline code", () => { + const text = "Keep ||literal||, **bold**, ~~deleted~~ and `a || b`."; + const prepared = prepareProse(text, noFiles); + expect(prepared).toContain("\\|\\|literal\\|\\|"); + expect(parser.render(prepared)).toBe(parser.render(text)); + }); + + it("keeps inline-code URLs tappable", () => { + expect(parser.render(prepareProse("`https://example.com`", noFiles))).toBe( + '

https://example.com

\n', + ); + }); + + it("retains links and makes inline workspace paths use the same resolver input", () => { + const prepared = prepareProse( + "[Docs](https://example.com) and `src/file.ts:12`", + (text) => text === "src/file.ts:12", + ); + const tokens = parser.parseInline(prepared, {})[0]!.children!; + const hrefs = tokens + .filter((token) => token.type === "link_open") + .map((token) => token.attrGet("href")); + expect(hrefs).toEqual(["https://example.com", "paseo-inline-code:src%2Ffile.ts%3A12"]); + expect(inlineCodeLinkContent(hrefs[1]!)).toBe("src/file.ts:12"); + }); + + it("preserves multiline quotes, literal pipes and code links", () => { + const text = "> Before ||literal||\n> after `src/file.ts`"; + const prepared = prepareProse(text, () => true); + expect(prepared).toContain("\\|\\|literal\\|\\|"); + expect(prepared).toContain("paseo-inline-code:"); + }); +}); + +const streamingParser = createAssistantMarkdownParser({ streaming: true }); +describe("streaming prose", () => { + it.each([ + "Before **bold", + "Before `code", + "Before [label](https://exa", + "### A **heading", + "- First\n- **Second", + "> First\n> **Second", + "Before **", + "Before ~~strike", + ])("retains provisional formatting: %s", (text) => { + expect(parser.render(prepareProse(text, noFiles, true))).toBe(streamingParser.render(text)); + }); +}); + +it("uppercases heading-six text without changing inline code or link destinations", () => { + expect( + parser.render( + prepareProse("###### Small **bold** `lower.ts` [link](https://example.com/lower)", noFiles), + ), + ).toBe( + '
SMALL BOLD lower.ts LINK
\n', + ); +}); diff --git a/packages/app/src/components/markdown-prose/prepare.ts b/packages/app/src/components/markdown-prose/prepare.ts new file mode 100644 index 0000000000..a8a052a0d3 --- /dev/null +++ b/packages/app/src/components/markdown-prose/prepare.ts @@ -0,0 +1,125 @@ +import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; +import type Token from "markdown-it/lib/token.mjs"; + +const parser = createAssistantMarkdownParser(); +const streamingParser = createAssistantMarkdownParser({ streaming: true }); +const INLINE_CODE_SCHEME = "paseo-inline-code:"; + +export function inlineCodeLinkContent(url: string): string | null { + return url.startsWith(INLINE_CODE_SCHEME) + ? decodeURIComponent(url.slice(INLINE_CODE_SCHEME.length)) + : null; +} + +function escapeText(text: string): string { + return text.replace(/&/g, "&").replace(/[\\`*_{}[\]<>()#+\-.!|~]/g, "\\$&"); +} + +function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean): string { + const links: string[] = []; + return tokens + .map((token) => { + switch (token.type) { + case "text": + return escapeText(token.content); + case "softbreak": + return "\n"; + case "hardbreak": + return " \n"; + case "link_open": + links.push(token.attrGet("href") ?? ""); + return "["; + case "link_close": { + return `](<${links.pop() ?? ""}>)`; + } + case "code_inline": { + const needsPadding = + /[^ ]/.test(token.content) && + (/^[ `]/.test(token.content) || /[ `]$/.test(token.content)); + const padding = needsPadding ? " " : ""; + const code = `${token.markup}${padding}${token.content}${padding}${token.markup}`; + if (links.length > 0) return code; + if (canResolveCode(token.content)) { + return `[${code}](<${INLINE_CODE_SCHEME}${encodeURIComponent(token.content)}>)`; + } + const matches = parser.linkify.match(token.content); + const match = matches?.length === 1 ? matches[0] : undefined; + if (match?.index === 0 && match.lastIndex === token.content.length) + return `[${code}](<${match.url}>)`; + return code; + } + default: + return token.markup || token.content; + } + }) + .join(""); +} + +/** Keep the existing inline dialect, including literal pipes and tappable inline paths. */ +export function prepareProse( + text: string, + canResolveCode: (text: string) => boolean, + streaming = false, +): string { + text = prepareBlockInlines(text, canResolveCode, streaming); + const state = new parser.inline.State(text, parser, {}, []); + let prepared = ""; + while (state.pos < state.posMax) { + const start = state.pos; + parser.inline.skipToken(state); + const source = text.slice(start, state.pos); + // Escapes and entities already protect their contents from delimiter parsing. + if (source.startsWith("\\") || source.startsWith("&")) { + prepared += source; + continue; + } + const tokens = parser.parseInline(source, {})[0]!.children!; + const containsLinkOrCode = tokens.some( + (token) => token.type === "code_inline" || token.type === "link_open", + ); + prepared += containsLinkOrCode + ? renderInline(tokens, canResolveCode) + : source.replace(/\|/g, "\\|"); + } + return prepared; +} + +function prepareBlockInlines( + text: string, + canResolveCode: (text: string) => boolean, + streaming: boolean, +): string { + const tokens = (streaming ? streamingParser : parser).parse(text, {}); + const tail = tokens.findLast((token) => token.nesting !== -1); + const lines = text.split("\n"); + for (let index = tokens.length - 1; index >= 0; index--) { + const token = tokens[index]!; + const uppercase = tokens[index - 1]?.type === "heading_open" && tokens[index - 1]?.tag === "h6"; + if ( + token.type !== "inline" || + !token.map || + !token.children || + !(uppercase || (streaming && token === tail)) + ) + continue; + const [start, end] = token.map; + const prefixes = token.content.split("\n").map((content, offset) => { + const line = lines[start + offset]!; + const position = line.indexOf(content); + return position < 0 ? "" : line.slice(0, position); + }); + if (uppercase) { + for (const child of token.children) { + if (child.type === "text") child.content = child.content.toUpperCase(); + } + } + const rendered = renderInline(token.children, canResolveCode) + .replace(/ $/, " ") + .split("\n"); + const replacement = rendered.map( + (line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`, + ); + lines.splice(start, end - start, ...replacement); + } + return lines.join("\n"); +} diff --git a/packages/app/src/components/markdown-prose/props.ts b/packages/app/src/components/markdown-prose/props.ts new file mode 100644 index 0000000000..9739aed4fb --- /dev/null +++ b/packages/app/src/components/markdown-prose/props.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export interface MarkdownProseProps { + text: string; + streaming: boolean; + kind: "prose" | "quote" | "block"; + onLinkPress: (url: string) => boolean; + children: ReactNode; +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 7915832470..bd62692246 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { MarkdownProse } from "@/components/markdown-prose"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -752,6 +753,7 @@ export const LiveElapsed = memo(function LiveElapsed({ }); interface AssistantMessageProps { + markdownKind?: "prose" | "quote" | "block"; renderFullContent?: boolean; occurrenceKey: string; message: string; @@ -1494,6 +1496,7 @@ function MarkdownListView({ } export const AssistantMessage = memo(function AssistantMessage({ + markdownKind = "block", renderFullContent = false, occurrenceKey, message, @@ -1959,7 +1962,10 @@ export const AssistantMessage = memo(function AssistantMessage({ }; }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); - const blocks = useMemo(() => splitMarkdownBlocks(revealedMessage), [revealedMessage]); + const blocks = useMemo( + () => (markdownKind === "block" ? splitMarkdownBlocks(revealedMessage) : []), + [revealedMessage, markdownKind], + ); const keyedBlocks = useMemo( () => blocks.map((block, index) => ({ key: `block:${index}`, block })), [blocks], @@ -1989,24 +1995,31 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( - {keyedBlocks.map(({ key, block }, index) => ( - - - - ))} + + {keyedBlocks.map(({ key, block }, index) => ( + + + + ))} + {fullMessageByteLength !== null ? (