From 5bf0e4b58892478e8ee035be1d98e5d899a9be7b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 04:52:34 +0200 Subject: [PATCH] fix(app): preserve inline image formatting and reference scope --- docs/native-text-selection.md | 37 ++++++++----------- .../app/src/agent-stream/presentation.test.ts | 4 ++ .../components/markdown-prose/prepare.test.ts | 7 ++++ .../src/components/markdown-prose/prepare.ts | 15 ++++++++ .../app/src/components/markdown-prose/runs.ts | 19 ++++++---- 5 files changed, 54 insertions(+), 28 deletions(-) diff --git a/docs/native-text-selection.md b/docs/native-text-selection.md index e18dbaca46..ba32514e7d 100644 --- a/docs/native-text-selection.md +++ b/docs/native-text-selection.md @@ -1,38 +1,33 @@ # Native text selection -Assistant prose uses `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view. Putting paragraphs in separate FlatList rows prevents dragging a selection between them. +Assistant prose uses `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. -Presentation groups adjacent paragraphs, headings, lists and horizontal rules into prose runs. Start another run before adding a block would exceed the existing 32,000-character render cap. A single oversized block retains its existing cap. Code, tables, images and HTML-like content keep Paseo's existing renderers. Blockquotes are separate rows rendered by the prose module. Selection stops at these boundaries; consecutive paragraphs inside a quote share its text segment. Nested quotes and embedded block content introduce further library segments. +Keep adjacent paragraphs, headings, lists and horizontal rules in prose runs. Code, tables, images and HTML-like content need Paseo's existing renderers. Preserve whole containers when splitting around an image would lose formatting or list context. Reference definitions retain their original block scope; grouping text for selection must not change which references resolve. -Keep this boundary in `agent-stream/presentation.ts` and `components/markdown-prose/`. The source message stays whole for plugins, copying a turn and persistence. Web keeps its existing block rows and renderer. The platform-specific `markdown-text` components remain in use by the preserved blocks and other Markdown surfaces. +Selection stops at preserved blocks, blockquotes and run boundaries required by the 32,000-character render cap. Consecutive paragraphs inside a quote share its text segment; nested quotes and embedded blocks introduce further library segments. Web keeps its existing renderer and selection behavior. ## Flavor -Use `github`. It retains strikethrough but renders blockquotes in separate native views. `commonmark` keeps a single text view across quotes but disables strikethrough and task-list parsing. A compiled parser check against the published 1.1.1 package produced: +Use `github` to retain strikethrough and gain task checkboxes. `commonmark` allows selection across quotes but loses strikethrough and task-list parsing. GitHub quotes require separate native views, so blockquotes are run boundaries. -```text -commonmark: strike=0 tasks=0 quotes=1 spoilers=1 -github: strike=1 tasks=1 quotes=1 spoilers=1 -``` - -GitHub flavor renders `[ ]` and `[x]` list items as checkboxes. Toggling is disabled because changes would not persist. Preserve task markers in both streaming and settled replies. +Checkbox toggling is disabled because changes would not persist. Admonitions add styling to `[!NOTE]`-style quotes without hiding their content. Ordinary quotes retain the existing blockquote styling. ## Compatibility constraints -- Disable `latexMath`. Dollar-delimited text must stay literal. Enable admonitions for `[!NOTE]`-style quotes; they add formatting without hiding the quote content. Ordinary quotes keep the existing blockquote style mapping. -- Spoilers have no public parser switch in 1.1.1. Prepare inline text with escaped pipes so `||text||` stays visible. Preserve pipes inside code and link destinations. -- `lineHeight` is a minimum. Use the Markdown theme's line height; font metrics can still increase it. +- Keep dollar-delimited text literal with `latexMath: false`; dollar amounts must not become math. +- Spoilers have no public parser switch in 1.1.1. Escape prose pipes so `||text||` stays visible; code and link destinations retain their pipes. +- `lineHeight` is a minimum, so font metrics can increase it. Compare spacing on devices. - Strikethrough color is iOS-only. Android uses the surrounding text color. -- Native h1/h2 lose their 1px bottom borders: the library has no heading-border style API. H6 plain text is uppercased before rendering; inline code and link destinations keep their source casing. H6 loses its 0.5 letter spacing. Size, weight, color, margins and line height keep the existing heading tokens. On Android, the library applies its heading span after the inline-code span: code inside headings takes the heading font, size and color despite the public code-style mapping. iOS applies the code style after the heading style. Web headings are unchanged. -- Published 1.1.1 has no `linkRecognition` or `linkPillContent` API. Explicit links use Paseo's link handler. Inline-code file candidates become ordinary links carrying their original resolver input. Native hover tooltips and custom React link pills are not available inside the text view. -- Reuse Paseo's provisional inline parser for the streaming tail so unfinished emphasis, code and links retain their existing display. Streaming updates replace the attributed text. Selection made while the selected run is still changing is not retained by the library. Keep the existing paced reveal and leave the library's additional streaming animation disabled. +- Strong text uses bold instead of the theme's weight 500. The public API accepts only bold or normal, and the app has no bundled medium-weight font family to substitute. +- Native h1/h2 lose their 1px bottom borders because the library has no heading-border API. H6 keeps uppercase plain text but loses its 0.5 letter spacing; code and link destinations keep their source casing. +- Android heading spans override inline code's font, size and color. iOS retains the code style; its system-monospace alias needs mapping at the library boundary. Web headings are unchanged. +- Published 1.1.1 cannot embed Paseo's custom React link pills or hover tooltips in its text view. Links retain Paseo's press handling. +- Streaming updates replace attributed text. The library does not retain selections made while the selected run is changing. -The app's `enriched-markdown` package configuration disables the math engine, code highlighting, all tree-sitter grammars and video support. Paseo owns those block renderers; shipping their duplicate native engines adds binary size without serving this surface. Rebuild the dev client after changing this configuration. +Keep the package's math, highlighting, tree-sitter and video engines disabled. Paseo owns those renderers; duplicate native engines add binary size without serving this surface. Rebuild the dev client after changing engine configuration. -Android inverted timelines retain their scroll protection in `modules/paseo-scroll`. Test selection-handle dragging against the compiled client: a JavaScript-only test cannot prove that Android's child-reveal request leaves the scroll offset unchanged. +Test Android selection-handle dragging against the compiled client. A JavaScript test cannot prove that the inverted timeline's scroll protection prevents movement during selection. ## Follow-ups -Contribute a fix for Android heading-span precedence so inline code retains its own font, size and color. - -Contribute heading `borderBottomWidth` and `borderBottomColor` style props upstream so native h1/h2 can restore their existing underline without breaking selection across headings. +Contribute Android heading-span precedence fixes and heading border style props upstream so inline code and h1/h2 borders retain their previous appearance without breaking selection across headings. diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 6c3622e272..c39142c5bc 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -148,6 +148,10 @@ describe("native prose runs", () => { it.each([ "![Screenshot][pic]\n\n[pic]: shots/app.png", + "**before ![alt](file.png) after**", + "# heading ![alt](file.png) after", + "[before ![alt](file.png) after](https://example.com)", + "~~before ![alt](file.png) after~~", "- parent\n - before ![x](shots/app.png) after\n - next", ])("keeps image context in Paseo's block renderer: %s", (text) => { const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]); diff --git a/packages/app/src/components/markdown-prose/prepare.test.ts b/packages/app/src/components/markdown-prose/prepare.test.ts index 7f2d882845..b3569547b6 100644 --- a/packages/app/src/components/markdown-prose/prepare.test.ts +++ b/packages/app/src/components/markdown-prose/prepare.test.ts @@ -105,3 +105,10 @@ it.each([ expect(streaming).toBe(settled); expect(parser.render(settled)).toBe(parser.render(text)); }); + +it.each([false, true])("keeps distant reference images literal (streaming=%s)", (streaming) => { + const text = "![shot][pic]\n\nOther paragraph.\n\n[pic]: shots/app.png"; + const prepared = prepareProse(text, noFiles, streaming); + expect(parser.render(prepared)).toContain("![shot][pic]"); + expect(parser.render(prepared)).not.toContain(" boolean, streaming = false, +): string { + // Reference definitions have the same scope as Paseo's original block rows. + const blocks = splitMarkdownBlocks(text); + return blocks + .map((block, index) => + prepareBlock(block, canResolveCode, streaming && index === blocks.length - 1), + ) + .join("\n\n"); +} + +function prepareBlock( + text: string, + canResolveCode: (text: string) => boolean, + streaming: boolean, ): string { const tokens = (streaming ? streamingParser : parser).parse(text, {}); const lines = text.split("\n"); diff --git a/packages/app/src/components/markdown-prose/runs.ts b/packages/app/src/components/markdown-prose/runs.ts index 974ed4fe5f..821b634d9d 100644 --- a/packages/app/src/components/markdown-prose/runs.ts +++ b/packages/app/src/components/markdown-prose/runs.ts @@ -1,5 +1,6 @@ import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit"; import MarkdownIt from "markdown-it"; +import type Token from "markdown-it/lib/token.mjs"; import type { AssistantMessageItem } from "@/types/stream"; const parser = new MarkdownIt({ html: true }); @@ -19,13 +20,7 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] { const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image")); let parts: AssistantMessageItem[]; // Retain the container and its definitions when splitting would lose image context. - if ( - preserved || - (hasImage && - (isQuote || - environment.references || - tokens.some((token) => token.type === "list_item_open"))) - ) { + if (preserved || (hasImage && (environment.references || !canSplitParagraphImages(tokens)))) { parts = [{ ...block, markdownKind: "block" }]; } else if (hasImage) { parts = splitParagraphImages(block); @@ -36,6 +31,16 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] { return parts; } +function canSplitParagraphImages(tokens: Token[]): boolean { + if (tokens.length !== 3 || tokens[0]?.type !== "paragraph_open") return false; + let depth = 0; + for (const child of tokens[1]!.children ?? []) { + if (child.type === "image" && depth !== 0) return false; + depth += child.nesting; + } + return true; +} + function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem[] { const parts: AssistantMessageItem[] = []; const state = new parser.inline.State(block.text, parser, {}, []);