fix(app): render selectable native prose with enriched markdown

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:01 +02:00
parent d8f79925d1
commit 75907b360b
10 changed files with 411 additions and 20 deletions
+18
View File
@@ -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",
+7
View File
@@ -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
+5 -1
View File
@@ -535,7 +535,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
const presentStream = useMemo(() => createStreamPresentation(), []);
const presentStream = useMemo(
() => createStreamPresentation({ markdown: isWeb ? "blocks" : "prose-runs" }),
[],
);
const presentation = useMemo(
() =>
presentStream({
@@ -726,6 +729,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
{(renderFullContent) => (
<AssistantMessage
markdownKind={item.markdownKind}
renderFullContent={renderFullContent}
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
@@ -0,0 +1,129 @@
import { memo, useMemo } from "react";
import { EnrichedMarkdownText, type MarkdownStyle } from "react-native-enriched-markdown";
import { withUnistyles } from "react-native-unistyles";
import { useAssistantFileLinkActions } from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { Theme } from "@/styles/theme";
import type { MarkdownProseProps } from "./props";
import { inlineCodeLinkContent, prepareProse } from "./prepare";
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: false };
function proseStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
const styles = createMarkdownStyles(theme);
const paragraph = {
color: styles.body.color,
fontSize: styles.body.fontSize,
lineHeight: styles.body.lineHeight,
marginTop: 0,
marginBottom: styles.paragraph.marginBottom + theme.spacing[3],
};
return {
markdownStyle: {
paragraph,
h1: {
...styles.heading1,
marginBottom:
styles.heading1.marginBottom + theme.spacing[3] + styles.heading1.paddingBottom,
},
h2: {
...styles.heading2,
marginBottom:
styles.heading2.marginBottom + theme.spacing[3] + styles.heading2.paddingBottom,
},
h3: { ...styles.heading3, marginBottom: styles.heading3.marginBottom + theme.spacing[3] },
h4: { ...styles.heading4, marginBottom: styles.heading4.marginBottom + theme.spacing[3] },
h5: { ...styles.heading5, marginBottom: styles.heading5.marginBottom + theme.spacing[3] },
h6: { ...styles.heading6, marginBottom: styles.heading6.marginBottom + theme.spacing[3] },
list: {
...paragraph,
bulletColor: styles.bullet_list_icon.color,
markerColor: styles.ordered_list_icon.color,
gapWidth: 7,
itemSpacing: styles.list_item.marginBottom,
marginLeft: theme.spacing[4],
},
blockquote: {
...paragraph,
color: styles.blockquote.color,
backgroundColor: styles.blockquote.backgroundColor,
borderColor: styles.blockquote.borderLeftColor,
borderWidth: styles.blockquote.borderLeftWidth,
borderRadius: styles.blockquote.borderRadius,
padding: styles.blockquote.paddingTop,
gapWidth: 4,
marginTop: styles.blockquote.marginVertical,
marginBottom: styles.blockquote.marginVertical,
},
link: { color: styles.link.color, underline: false },
code: { ...styles.code_inline, borderColor: "transparent" },
strong: styles.strong,
em: styles.em,
strikethrough: { color: styles.s.color },
thematicBreak: {
color: styles.hr.backgroundColor,
height: styles.hr.height,
marginTop: styles.hr.marginVertical,
marginBottom: styles.hr.marginVertical,
},
},
};
}
function quoteStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
const mapped = proseStyleMapping(theme);
return {
markdownStyle: {
...mapped.markdownStyle,
paragraph: { ...mapped.markdownStyle.paragraph, marginBottom: theme.spacing[3] },
},
};
}
const PROSE_CONTAINER = { marginBottom: -12 };
const NativeProse = memo(function NativeProse({
text,
streaming,
kind,
onLinkPress,
}: MarkdownProseProps) {
const actions = useAssistantFileLinkActions();
const canResolveCode = useStableEvent((content: string) =>
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 (
<ThemedMarkdown
uniProps={kind === "quote" ? quoteStyleMapping : proseStyleMapping}
containerStyle={kind === "prose" ? PROSE_CONTAINER : undefined}
markdown={markdown}
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
allowTrailingMargin
streamingAnimation={false}
onLinkPress={handleLinkPress}
/>
);
});
export function MarkdownProse(props: MarkdownProseProps) {
return props.kind === "block" ? props.children : <NativeProse {...props} />;
}
@@ -0,0 +1,2 @@
// TypeScript's platform-neutral entry; Metro chooses the native or web implementation.
export { MarkdownProse } from "./index.web";
@@ -0,0 +1,5 @@
import type { MarkdownProseProps } from "./props";
export function MarkdownProse({ children }: MarkdownProseProps) {
return children;
}
@@ -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 &amp;amp; and &amp;copy;",
"# #",
"- 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(
'<p><a href="https://example.com"><code>https://example.com</code></a></p>\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(
'<h6>SMALL <strong>BOLD</strong> <code>lower.ts</code> <a href="https://example.com/lower">LINK</a></h6>\n',
);
});
@@ -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, "&amp;").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(/ $/, "&#32;")
.split("\n");
const replacement = rendered.map(
(line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`,
);
lines.splice(start, end - start, ...replacement);
}
return lines.join("\n");
}
@@ -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;
}
+32 -19
View File
@@ -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 (
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
>
<MemoizedMarkdownBlock
text={block}
rules={markdownRules}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
))}
<MarkdownProse
streaming={phase === "streaming"}
kind={markdownKind}
text={revealedMessage}
onLinkPress={handleMarkdownLinkPress}
>
{keyedBlocks.map(({ key, block }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
>
<MemoizedMarkdownBlock
text={block}
rules={markdownRules}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
))}
</MarkdownProse>
{fullMessageByteLength !== null ? (
<Text
testID="assistant-message-capped-notice"