From 2db3488dbd5613b6c6b5a324f9154b58b1236c2e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 10:00:56 +0200 Subject: [PATCH] fix(app): preserve Markdown containers before grouping prose --- docs/native-text-selection.md | 2 +- .../app/src/assistant-file-links/index.ts | 2 +- .../components/markdown/part-groups.test.ts | 24 +++++++- .../src/components/markdown/part-groups.ts | 18 +++++- .../src/components/markdown/prose/props.ts | 2 +- .../app/src/components/markdown/renderer.tsx | 58 ++++++++++++------- 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/docs/native-text-selection.md b/docs/native-text-selection.md index c4bd6ab330..07ba311b70 100644 --- a/docs/native-text-selection.md +++ b/docs/native-text-selection.md @@ -2,7 +2,7 @@ The shared `components/markdown/renderer.tsx` renders native prose with `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view, so separate paragraph rows prevent dragging a selection between them. -Every `MarkdownRenderer` consumer gets prose runs without surface-specific wiring: chat, Markdown file previews, plan cards, pull request activity and changelogs. The renderer owns classification, native preparation and the platform component under `components/markdown/`. Its pure `parts` boundary groups adjacent paragraphs, headings, lists and horizontal rules; code, tables, images and HTML-like content retain Paseo's block renderers. Keep whole containers when splitting around an image would lose formatting or list context. +Every `MarkdownRenderer` consumer gets prose runs without surface-specific wiring: chat, Markdown file previews, plan cards, pull request activity and changelogs. The renderer owns classification, native preparation and the platform component under `components/markdown/`. Its pure `parts` boundary groups adjacent paragraphs, headings, lists and horizontal rules; code, tables, images and HTML-like content retain Paseo's block renderers. Parse HTML-like containers and image/text layout before grouping prose; blank-line splitting of the raw document severs `
` from its body. Expanded bodies use the same renderer. Keep whole containers when splitting around an image would lose formatting or list context. Chat uses the same grouper to create virtualized display rows. Presentation owns message-to-row ids, promoted/history identity and the growing last source block. It passes an already grouped part to `MarkdownRenderer`; the growing block uses the existing streaming parser and joins the preceding run when another block starts or the turn ends. Selection across that paragraph becomes available at completion. Chat supplies its file-link actions; native prose does not require a chat provider. Other surfaces keep their parser's link policy and compact typography. diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 723945409f..7cecbfa9b6 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -14,4 +14,4 @@ export { type AssistantFileLinkResolverProviderProps, } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; -export { useAssistantFileLinkActions } from "./use-file-link"; +export { useAssistantFileLinkActions, type AssistantFileLinkActions } from "./use-file-link"; diff --git a/packages/app/src/components/markdown/part-groups.test.ts b/packages/app/src/components/markdown/part-groups.test.ts index 6d9ae8fcaa..0f9876e249 100644 --- a/packages/app/src/components/markdown/part-groups.test.ts +++ b/packages/app/src/components/markdown/part-groups.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { MarkdownDisplayPart } from "./html-ish"; -import { groupMarkdownParts } from "./part-groups"; +import { groupMarkdownParts, prepareMarkdownGroups } from "./part-groups"; import { parseMarkdownPreviewDocument } from "@/file-pane/markdown-preview/document"; import { createProsePreparer } from "./prose/prepare"; import { splitMarkdownProse } from "./parts"; @@ -130,3 +130,25 @@ describe("Markdown file preview document", () => { expect(parts[1]?.references?.PIC?.href).toBe("image.png"); }); }); + +describe("renderer container ordering", () => { + it("retains a collapsible before preparing its prose", () => { + const groups = prepareMarkdownGroups({ + text: "Before.\n\n
\nMore\n\nFirst paragraph.\n\nSecond paragraph.\n\n
\n\nAfter.", + enableHtmlish: true, + }); + expect(groups.map((group) => (group.kind === "part" ? group.part.kind : group.kind))).toEqual([ + "markdown", + "details", + "markdown", + ]); + const details = groups[1]; + expect(details?.kind).toBe("part"); + if (details?.kind !== "part" || details.part.kind !== "details") + throw new Error("Missing collapsible"); + expect(details.part.summary).toBe("More"); + expect(splitMarkdownProse(details.part.body).map((part) => part.markdownKind)).toEqual([ + "prose", + ]); + }); +}); diff --git a/packages/app/src/components/markdown/part-groups.ts b/packages/app/src/components/markdown/part-groups.ts index 016152fb00..69b172a60d 100644 --- a/packages/app/src/components/markdown/part-groups.ts +++ b/packages/app/src/components/markdown/part-groups.ts @@ -1,9 +1,25 @@ -import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish"; +import { + splitHtmlishMarkdown, + type MarkdownDisplayPart, + type MarkdownInlineImagePart, +} from "./html-ish"; export type MarkdownPartGroup = | { kind: "part"; part: MarkdownDisplayPart } | { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string }; +/** Parse containers before splitting their Markdown text into selectable runs. */ +export function prepareMarkdownGroups({ + text, + enableHtmlish, +}: { + text: string; + enableHtmlish: boolean; +}): MarkdownPartGroup[] { + const parts = enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]; + return groupMarkdownParts(parts); +} + /** * Pairs a run of inline images that flow with text (small status images at the * start of a line, GitHub-style) with the lead paragraph of the following diff --git a/packages/app/src/components/markdown/prose/props.ts b/packages/app/src/components/markdown/prose/props.ts index dcb95de424..ee3db840cc 100644 --- a/packages/app/src/components/markdown/prose/props.ts +++ b/packages/app/src/components/markdown/prose/props.ts @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import type { AssistantFileLinkActions } from "@/assistant-file-links/use-file-link"; +import type { AssistantFileLinkActions } from "@/assistant-file-links"; import type { MarkdownReferences } from "../parts"; diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 8eeda92494..13e95285ef 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -33,22 +33,18 @@ import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/mark import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; import { isNative } from "@/constants/platform"; -import { - splitHtmlishMarkdown, - type MarkdownDisplayPart, - type MarkdownInlineImagePart, -} from "./html-ish"; +import { type MarkdownDisplayPart, type MarkdownInlineImagePart } from "./html-ish"; import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size"; import { getAssistantImageMetadata, setAssistantImageMetadata, } from "@/utils/assistant-image-metadata"; -import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups"; +import { groupMarkdownParts, prepareMarkdownGroups, type MarkdownPartGroup } from "./part-groups"; import { colorMarkdownLinkChildren } from "./link-children"; import { MarkdownLinkText } from "./link-text"; import { MarkdownProse } from "./prose/renderer"; import { splitMarkdownProse, type MarkdownProsePart } from "./parts"; -import type { AssistantFileLinkActions } from "@/assistant-file-links/use-file-link"; +import type { AssistantFileLinkActions } from "@/assistant-file-links"; export type MarkdownStyles = Record; @@ -94,12 +90,17 @@ export interface MarkdownRendererProps { } export function MarkdownRenderer(props: MarkdownRendererProps) { + if (isNative && props.part) return ; + return ; +} + +const BLOCK_PART = { markdownKind: "block" as const }; + +function MarkdownRuns(props: MarkdownRendererProps) { const parts = useMemo(() => { - if (!isNative) return []; if (props.part) return [{ ...props.part, text: props.text, id: "part" }]; return splitMarkdownProse(props.text); }, [props.part, props.text]); - if (!isNative) return ; const parser = props.markdownit ?? defaultMarkdownParser; return ( <> @@ -108,7 +109,7 @@ export function MarkdownRenderer(props: MarkdownRendererProps) { key={part.id} text={part.text} sourceBlocks={part.markdownProseBlocks ?? [part.text]} - kind={part.markdownKind ?? "block"} + kind={part.markdownKind} compact={props.compact} blockSpacing={props.blockSpacing} fileLinks={props.fileLinks} @@ -117,7 +118,12 @@ export function MarkdownRenderer(props: MarkdownRendererProps) { allowFileLinks={parser.validateLink("file:///file")} onLinkPress={props.onLinkPress ?? openMarkdownLink} > - + ))} @@ -162,10 +168,11 @@ function MarkdownBlockRenderer({ allowedImageHandlers, topLevelMaxExceededItem, enableHtmlish = true, + part, }: MarkdownRendererProps) { const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]); - const parts = useMemo( - () => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]), + const groups = useMemo( + () => prepareMarkdownGroups({ text, enableHtmlish }), [enableHtmlish, text], ); const rendererProps = useMemo( @@ -176,6 +183,7 @@ function MarkdownBlockRenderer({ onLinkPress, allowedImageHandlers, topLevelMaxExceededItem, + part, }), [ allowedImageHandlers, @@ -184,24 +192,25 @@ function MarkdownBlockRenderer({ markdownit, onLinkPress, topLevelMaxExceededItem, + part, ], ); - return ; + return ; } type MarkdownPartRendererProps = Omit & { rules: RenderRules; }; -function MarkdownPartList({ - parts, +function MarkdownGroupList({ + groups, rendererProps, }: { - parts: MarkdownDisplayPart[]; + groups: MarkdownPartGroup[]; rendererProps: MarkdownPartRendererProps; }) { - const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]); + const keyedGroups = useMemo(() => keyMarkdownGroups(groups), [groups]); return ( <> {keyedGroups.map(({ key, group }) => @@ -243,7 +252,12 @@ function MarkdownPart({ return ; } -function MarkdownFragment({ +function MarkdownFragment(props: MarkdownRendererProps & { rules: RenderRules }) { + if (isNative && !props.part) return ; + return ; +} + +function MarkdownBlockFragment({ text, compact, rules, @@ -443,8 +457,8 @@ function MarkdownDetails({ }) { const [open, setOpen] = useState(false); const toggle = useCallback(() => setOpen((current) => !current), []); - const bodyParts = useMemo( - () => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }], + const bodyGroups = useMemo( + () => groupMarkdownParts(part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }]), [part.body, part.bodyParts], ); return ( @@ -459,7 +473,7 @@ function MarkdownDetails({ {open ? ( - + ) : null}