diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 258bd77e5..eed1bc283 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -9,6 +9,7 @@ import { ViewStyle, Platform, } from "react-native"; +import * as React from "react"; import { useState, useEffect, @@ -23,7 +24,7 @@ import { cloneElement, } from "react"; import type { ReactNode, ComponentType } from "react"; -import Markdown, { MarkdownIt } from "react-native-markdown-display"; +import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, @@ -72,6 +73,7 @@ import { import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; export type { InlinePathTarget } from "@/utils/inline-path"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; @@ -712,6 +714,28 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ }, })); +interface MemoizedMarkdownBlockProps { + text: string; + styles: ReturnType; + rules: RenderRules; + parser: MarkdownIt; + onLinkPress: (url: string) => boolean; +} + +const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ + text, + styles, + rules, + parser, + onLinkPress, +}: MemoizedMarkdownBlockProps) { + return ( + + {text} + + ); +}); + export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, @@ -895,6 +919,8 @@ export const AssistantMessage = memo(function AssistantMessage({ }; }, [handleLinkPress, markdownParser, onInlinePathPress]); + const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); + return ( - - {message} - + {blocks.map((block, index) => ( + + + + ))} ); }); diff --git a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts new file mode 100644 index 000000000..a014708fa --- /dev/null +++ b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { splitMarkdownBlocks } from "../split-markdown-blocks"; + +describe("splitMarkdownBlocks", () => { + it("returns a single block for a single paragraph", () => { + expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]); + }); + + it("splits two paragraphs separated by a double newline", () => { + expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([ + "First paragraph", + "Second paragraph", + ]); + }); + + it("keeps a fenced code block with internal double newlines as one block", () => { + expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([ + "```ts\nconst a = 1;\n\nconst b = 2;\n```", + ]); + }); + + it("does not treat 4-space-indented backticks as a fence", () => { + expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([ + "Before", + " ```\n code\n ```", + "After", + ]); + }); + + it("handles tilde fences", () => { + expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([ + "Before", + "~~~\ncode\n~~~", + "After", + ]); + }); + + it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => { + expect( + splitMarkdownBlocks("Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph"), + ).toEqual([ + "Intro paragraph", + "```ts\nconst a = 1;\n\nconst b = 2;\n```", + "Outro paragraph", + ]); + }); + + it("keeps everything from an unclosed fence start as one block for streaming content", () => { + expect( + splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;"), + ).toEqual(["Before fence", "```ts\nconst a = 1;\n\nconst b = 2;"]); + }); + + it("returns an empty array for empty input", () => { + expect(splitMarkdownBlocks("")).toEqual([]); + }); + + it("splits a heading followed by a paragraph into two blocks", () => { + expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([ + "# Heading", + "Paragraph text", + ]); + }); + + it("keeps consecutive list items together when there is no double newline", () => { + expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([ + "- First item\n- Second item\n- Third item", + ]); + }); + + it("treats triple newlines as a split point and filters empty blocks", () => { + expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([ + "First paragraph", + "Second paragraph", + ]); + }); +}); diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts new file mode 100644 index 000000000..4a6942872 --- /dev/null +++ b/packages/app/src/utils/split-markdown-blocks.ts @@ -0,0 +1,60 @@ +function getFenceDelimiter(line: string) { + const match = /^( {0,3})(`{3,}|~{3,})/.exec(line); + return match?.[2] ?? null; +} + +export function splitMarkdownBlocks(text: string): string[] { + if (text.length === 0) { + return []; + } + + const blocks: string[] = []; + let currentLines: string[] = []; + let activeFenceCharacter: "`" | "~" | null = null; + let activeFenceLength = 0; + let sawBlockSeparator = false; + + for (const line of text.split("\n")) { + const isBlankLine = line.trim().length === 0; + + if (!activeFenceCharacter && isBlankLine) { + if (currentLines.length > 0) { + sawBlockSeparator = true; + } + continue; + } + + if (!activeFenceCharacter && sawBlockSeparator) { + blocks.push(currentLines.join("\n")); + currentLines = []; + sawBlockSeparator = false; + } + + currentLines.push(line); + + const fenceDelimiter = getFenceDelimiter(line); + if (!fenceDelimiter) { + continue; + } + + if (!activeFenceCharacter) { + activeFenceCharacter = fenceDelimiter[0] as "`" | "~"; + activeFenceLength = fenceDelimiter.length; + continue; + } + + if ( + fenceDelimiter[0] === activeFenceCharacter && + fenceDelimiter.length >= activeFenceLength + ) { + activeFenceCharacter = null; + activeFenceLength = 0; + } + } + + if (currentLines.length > 0) { + blocks.push(currentLines.join("\n")); + } + + return blocks.filter((block) => block.length > 0); +}