mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
fix(app): render selectable native prose with enriched markdown
This commit is contained in:
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; and &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, "&").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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user