From 17cb87ee018e65f925df2db1e7114a63729d2bfd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 01:31:41 +0000 Subject: [PATCH 01/13] fix(mobile): chat code blocks colour diff, kotlin, sql and other languages again Since chat markdown moved to react-native-enriched-markdown, fences in languages outside its default tree-sitter grammars (diff, kotlin, sql, swift, ruby, dart, ...) render as plain text on mobile, while web colours them. Once such a fence closes, the code renderer now draws it with the app's own highlighter in a block that copies the native one: language header, wrap toggle, copy button, and the code block's font and colours. Natively coloured, unknown, unlabelled and still-streaming fences keep the native block. The Mermaid block shares the same frame and header. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/native/CodeBlockFrame.tsx | 98 ++++++++++++ .../src/native/EnrichedMarkdownText.tsx | 29 +++- .../src/native/HighlightedCodeBlock.tsx | 146 ++++++++++++++++++ apps/mobile/src/native/MermaidCodeBlock.tsx | 76 ++------- 4 files changed, 285 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/native/CodeBlockFrame.tsx create mode 100644 apps/mobile/src/native/HighlightedCodeBlock.tsx diff --git a/apps/mobile/src/native/CodeBlockFrame.tsx b/apps/mobile/src/native/CodeBlockFrame.tsx new file mode 100644 index 0000000000000..335a1d3183b6a --- /dev/null +++ b/apps/mobile/src/native/CodeBlockFrame.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +/** + * The native code block's border and header, for a fence drawn in React once it closes. + * The block slot already applies the code block's outer margins. + */ +export function CodeBlockFrame(props: { + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly actions: ReactNode; + readonly children: ReactNode; +}) { + const { textStyle } = props; + return ( + + + + {props.label} + + {props.actions} + + {props.children} + + ); +} + +/** A header icon; `active` draws it at full strength, as the native wrap toggle when on. */ +export function CodeBlockHeaderButton(props: { + readonly icon: AppSymbolName; + readonly label: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly active?: boolean; + readonly onPress: () => void; +}) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + block: { + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + overflow: "hidden", + }, + header: { + height: 32, + paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderBottomWidth: StyleSheet.hairlineWidth, + }, + label: { fontWeight: "500" }, + actions: { flexDirection: "row", alignItems: "center", gap: 14 }, +}); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 16bab62f09372..f882c31aff6ed 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -20,6 +20,7 @@ import { type MarkdownLinkOccurrence, type MarkdownStyle, } from "react-native-enriched-markdown"; +import { fallbackHighlightLanguage, HighlightedCodeBlock } from "./HighlightedCodeBlock"; import { MermaidCodeBlock } from "./MermaidCodeBlock"; import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; @@ -29,6 +30,7 @@ import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; import { useRetryingImageSource } from "./useRetryingImageSource"; import * as Dialog from "../components/ui/Dialog"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import type { HighlightScheme } from "../lib/highlights"; import { ENRICHED_INLINE_FILE_LINK_REGEX, enrichedTextLinkRegex, @@ -166,12 +168,16 @@ function enrichedStyle( }; } -/** Diagram and math fences, as web draws them. A fence streams as native code until it closes. */ +/** + * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour. + * A fence streams as native code until it closes. + */ function codeBlockElement( block: MarkdownCodeBlock, textStyle: SelectableMarkdownTextProps["textStyle"], markdownStyle: MarkdownStyle, mathRenderingEnabled: boolean, + scheme: HighlightScheme, ): ReactElement | undefined { if (!block.isComplete) return undefined; if (block.language === "mermaid") { @@ -192,6 +198,19 @@ function codeBlockElement( /> ); } + const language = fallbackHighlightLanguage(block.language); + if (language && markdownStyle.codeBlock) { + return ( + + ); + } return undefined; } @@ -347,7 +366,13 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { } : undefined, code: (block: MarkdownCodeBlock) => - codeBlockElement(block, props.textStyle, baseMarkdownStyle, mathRenderingEnabled), + codeBlockElement( + block, + props.textStyle, + baseMarkdownStyle, + mathRenderingEnabled, + themeAppearance, + ), }; const resolveNativeImageSource = useRetryingImageSource(resolveImageSource); const renderMarkdown = (openAndroidMenu?: () => void) => { diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx new file mode 100644 index 0000000000000..fe5625499fee1 --- /dev/null +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { ScrollView, Text, View } from "react-native"; +import type { MarkdownStyle } from "react-native-enriched-markdown"; + +import { useMarkdownCodeHighlight } from "../features/threads/markdownCodeHighlightState"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import { resolveHighlightLanguage } from "../lib/highlightLanguage"; +import type { HighlightScheme } from "../lib/highlights"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +// Fences enriched-markdown colours itself: cpp/highlight/CodeBlockLanguages.cpp entries with a default grammar. +const NATIVE_HIGHLIGHT_FENCES = new Set([ + "bash", + "c", + "css", + "go", + "golang", + "html", + "java", + "javascript", + "js", + "json", + "jsx", + "markdown", + "md", + "py", + "python", + "rs", + "rust", + "sh", + "shell", + "ts", + "tsx", + "typescript", + "yaml", + "yml", + "zsh", +]); + +// The same file's header names that are not the fence capitalized. +const FENCE_DISPLAY_NAMES: Readonly> = { + cc: "C++", + cpp: "C++", + cxx: "C++", + cs: "C#", + csharp: "C#", + graphql: "GraphQL", + objc: "Objective-C", + objectivec: "Objective-C", + php: "PHP", + rb: "Ruby", + scss: "SCSS", + sql: "SQL", + toml: "TOML", + xml: "XML", +}; + +/** The highlights language for a fence enriched-markdown leaves plain, or null to keep its block. */ +export function fallbackHighlightLanguage(fence: string): string | null { + const name = fence.toLowerCase(); + if (NATIVE_HIGHLIGHT_FENCES.has(name)) return null; + const language = resolveHighlightLanguage({ language: name }); + return language === "text" ? null : language; +} + +/** A complete fence in a language enriched-markdown cannot colour, drawn like its native block. */ +export function HighlightedCodeBlock(props: { + readonly code: string; + readonly fence: string; + readonly language: string; + readonly scheme: HighlightScheme; + readonly textStyle: NativeMarkdownTextStyle; + readonly codeStyle: NonNullable; +}) { + const { code, codeStyle, textStyle } = props; + const [wrapped, setWrapped] = useState(false); + const lines = useMarkdownCodeHighlight({ + code, + language: props.language, + theme: props.scheme, + enabled: true, + }); + const fence = props.fence.toLowerCase(); + let offset = 0; + const text = ( + + {lines?.flatMap((line, lineIndex) => { + const separator = lineIndex > 0 ? "\n" : ""; + offset += separator.length; + return [ + separator, + ...line.map((token) => { + const start = offset; + offset += token.content.length; + return ( + + {token.content} + + ); + }), + ]; + }) ?? code} + + ); + const padding = { padding: codeStyle.padding }; + return ( + + setWrapped((value) => !value)} + /> + copyTextWithHaptic(code)} + /> + + } + > + {wrapped ? ( + {text} + ) : ( + + {text} + + )} + + ); +} diff --git a/apps/mobile/src/native/MermaidCodeBlock.tsx b/apps/mobile/src/native/MermaidCodeBlock.tsx index 345f99b1b6355..154c7e11ee174 100644 --- a/apps/mobile/src/native/MermaidCodeBlock.tsx +++ b/apps/mobile/src/native/MermaidCodeBlock.tsx @@ -7,10 +7,10 @@ import { PixelRatio, Platform, Pressable, StyleSheet, Text, View } from "react-n import { parse, Svg, SvgXml } from "react-native-svg"; import { FilePreviewModal } from "../components/ui/FilePreviewModal"; -import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { mermaidSvgForNative, withDiagramBackground } from "../lib/mermaidSvg"; import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; type Pele = typeof import("pele/lazy"); @@ -191,43 +191,29 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram ); }; - // The header copies the native code block's, which this block replaces once the fence closes. return ( - - - - Mermaid - - + {svg ? ( - setShowCode((value) => !value)} /> ) : null} - copyTextWithHaptic(code)} /> - - + + } + > {diagram?.status === "error" ? ( @@ -296,45 +282,11 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram onRequestClose={() => setExpansion(null)} /> ) : null} - - ); -} - -function HeaderButton(props: { - readonly icon: AppSymbolName; - readonly label: string; - readonly color: string; - readonly onPress: () => void; -}) { - return ( - - - + ); } -// The block slot already applies the code block's outer margins. const styles = StyleSheet.create({ - block: { - borderRadius: 8, - borderWidth: StyleSheet.hairlineWidth, - overflow: "hidden", - }, - header: { - height: 32, - paddingHorizontal: 12, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderBottomWidth: StyleSheet.hairlineWidth, - }, - language: { fontWeight: "500" }, - actions: { flexDirection: "row", alignItems: "center", gap: 14 }, content: { padding: 12 }, diagramRow: { flexDirection: "row", justifyContent: "center" }, error: { marginBottom: 8 }, From a230081021d8806038bfa5d7084f85276d929bfd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 01:44:25 +0000 Subject: [PATCH 02/13] fix(mobile): keep the fallback code block mounted while its fence streams The highlighted fallback now renders from the opening fence, with highlighting deferred until the fence closes, so a wrap toggle and the block's height carry across completion instead of swapping blocks. Co-Authored-By: Claude Opus 5.5 --- .../src/native/EnrichedMarkdownText.tsx | 29 ++++++++++--------- .../src/native/HighlightedCodeBlock.tsx | 6 ++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index f882c31aff6ed..427238598b115 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -170,7 +170,7 @@ function enrichedStyle( /** * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour. - * A fence streams as native code until it closes. + * Diagrams and math stream as native code until they close; fallback code keeps one block throughout. */ function codeBlockElement( block: MarkdownCodeBlock, @@ -179,6 +179,20 @@ function codeBlockElement( mathRenderingEnabled: boolean, scheme: HighlightScheme, ): ReactElement | undefined { + const language = fallbackHighlightLanguage(block.language); + if (language && markdownStyle.codeBlock) { + return ( + + ); + } if (!block.isComplete) return undefined; if (block.language === "mermaid") { return ; @@ -198,19 +212,6 @@ function codeBlockElement( /> ); } - const language = fallbackHighlightLanguage(block.language); - if (language && markdownStyle.codeBlock) { - return ( - - ); - } return undefined; } diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index fe5625499fee1..9c5995e59013e 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -64,11 +64,13 @@ export function fallbackHighlightLanguage(fence: string): string | null { return language === "text" ? null : language; } -/** A complete fence in a language enriched-markdown cannot colour, drawn like its native block. */ +/** A fence in a language enriched-markdown cannot colour, drawn like its native block. */ export function HighlightedCodeBlock(props: { readonly code: string; readonly fence: string; readonly language: string; + /** Highlighting waits for the closing fence, as the native block's does. */ + readonly complete: boolean; readonly scheme: HighlightScheme; readonly textStyle: NativeMarkdownTextStyle; readonly codeStyle: NonNullable; @@ -79,7 +81,7 @@ export function HighlightedCodeBlock(props: { code, language: props.language, theme: props.scheme, - enabled: true, + enabled: props.complete, }); const fence = props.fence.toLowerCase(); let offset = 0; From 33151da49d4ca95a8555806e8f9ac076edf7bb1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 03:14:42 +0000 Subject: [PATCH 03/13] docs(mobile): say when a code fence counts as complete Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/native/EnrichedMarkdownText.tsx | 2 +- apps/mobile/src/native/HighlightedCodeBlock.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 427238598b115..944b17cf1980e 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -170,7 +170,7 @@ function enrichedStyle( /** * Diagram and math fences, as web draws them, and code enriched-markdown cannot colour. - * Diagrams and math stream as native code until they close; fallback code keeps one block throughout. + * Diagrams and math stay native until the library reports them complete; fallback code keeps one block throughout. */ function codeBlockElement( block: MarkdownCodeBlock, diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index 9c5995e59013e..ff7021a0152a0 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -69,7 +69,7 @@ export function HighlightedCodeBlock(props: { readonly code: string; readonly fence: string; readonly language: string; - /** Highlighting waits for the closing fence, as the native block's does. */ + /** False only while the library withholds an unclosed fence (`streamingAnimation`); highlighting waits for it. */ readonly complete: boolean; readonly scheme: HighlightScheme; readonly textStyle: NativeMarkdownTextStyle; From 0c6994dceb238aa76db19ca22524714ee0982ce7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 03:46:51 +0000 Subject: [PATCH 04/13] fix(mobile): mermaid and math fences keep their renderers ahead of the code fallback @pierre/highlights 0.5.0 knows mermaid, so the fallback must not run first. Co-Authored-By: Claude Opus 5.5 --- .../src/native/EnrichedMarkdownText.tsx | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 944b17cf1980e..56d313c13dfba 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -179,26 +179,13 @@ function codeBlockElement( mathRenderingEnabled: boolean, scheme: HighlightScheme, ): ReactElement | undefined { - const language = fallbackHighlightLanguage(block.language); - if (language && markdownStyle.codeBlock) { - return ( - - ); - } - if (!block.isComplete) return undefined; if (block.language === "mermaid") { - return ; + return block.isComplete ? ( + + ) : undefined; } if (block.language === "math" && mathRenderingEnabled) { - return ( + return block.isComplete ? ( + ) : undefined; + } + const language = fallbackHighlightLanguage(block.language); + if (language && markdownStyle.codeBlock) { + return ( + ); } return undefined; From bda955f68fe6c373315d32b261f8834ae41e10c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 04:36:07 +0000 Subject: [PATCH 05/13] fix(mobile): highlighted code fences keep the long-press copy menu The React fallback for fences enriched-markdown cannot colour dropped the native block's long-press menu. Long-pressing its code now offers Copy and Copy as Markdown, and the code text is no longer selectable, as natively. Co-Authored-By: Claude Opus 5.5 --- .../src/native/HighlightedCodeBlock.tsx | 38 ++++++++++++++----- .../src/native/codeBlockMarkdown.test.ts | 13 +++++++ apps/mobile/src/native/codeBlockMarkdown.ts | 7 ++++ 3 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/native/codeBlockMarkdown.test.ts create mode 100644 apps/mobile/src/native/codeBlockMarkdown.ts diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index ff7021a0152a0..16cdd9ddc2dc6 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; -import { ScrollView, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; import type { MarkdownStyle } from "react-native-enriched-markdown"; +import { Menu } from "../components/ui/Menu"; import { useMarkdownCodeHighlight } from "../features/threads/markdownCodeHighlightState"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { resolveHighlightLanguage } from "../lib/highlightLanguage"; import type { HighlightScheme } from "../lib/highlights"; import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; +import { fencedCodeMarkdown } from "./codeBlockMarkdown"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; // Fences enriched-markdown colours itself: cpp/highlight/CodeBlockLanguages.cpp entries with a default grammar. @@ -56,6 +58,12 @@ const FENCE_DISPLAY_NAMES: Readonly> = { xml: "XML", }; +// The native block's long-press menu, with its labels and iOS symbols. +const CODE_MENU_ACTIONS = [ + { id: "copy-code", title: "Copy", image: "doc.on.doc" }, + { id: "copy-markdown", title: "Copy as Markdown", image: "doc.text" }, +]; + /** The highlights language for a fence enriched-markdown leaves plain, or null to keep its block. */ export function fallbackHighlightLanguage(fence: string): string | null { const name = fence.toLowerCase(); @@ -86,8 +94,8 @@ export function HighlightedCodeBlock(props: { const fence = props.fence.toLowerCase(); let offset = 0; const text = ( + // Not selectable: as in the native block, a long-press opens the copy menu instead. } > - {wrapped ? ( - {text} - ) : ( - - {text} - - )} + { + copyTextWithHaptic( + nativeEvent.event === "copy-markdown" ? fencedCodeMarkdown(code, props.fence) : code, + ); + }} + > + + {wrapped ? ( + {text} + ) : ( + + {text} + + )} + + ); } diff --git a/apps/mobile/src/native/codeBlockMarkdown.test.ts b/apps/mobile/src/native/codeBlockMarkdown.test.ts new file mode 100644 index 0000000000000..24031e78d3f7a --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fencedCodeMarkdown } from "./codeBlockMarkdown"; + +describe("fencedCodeMarkdown", () => { + it.each([ + ["a plain fence", "fun main() {}", "kotlin", "```kotlin\nfun main() {}\n```"], + ["a longer fence than the code's", "```\nx\n```", "md", "````md\n```\nx\n```\n````"], + ["no language", "x", "", "```\nx\n```"], + ])("writes %s", (_, code, language, expected) => { + expect(fencedCodeMarkdown(code, language)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/native/codeBlockMarkdown.ts b/apps/mobile/src/native/codeBlockMarkdown.ts new file mode 100644 index 0000000000000..9b06066bcb762 --- /dev/null +++ b/apps/mobile/src/native/codeBlockMarkdown.ts @@ -0,0 +1,7 @@ +/** A fenced code block for `code`, with a backtick fence longer than any backtick run inside it. */ +export function fencedCodeMarkdown(code: string, language: string): string { + let longestRun = 0; + for (const [run] of code.matchAll(/`+/g)) longestRun = Math.max(longestRun, run.length); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return `${fence}${language}\n${code}\n${fence}`; +} From 25fea911d44860e207263ddc865a43b025f6cf6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 06:40:44 +0000 Subject: [PATCH 06/13] fix(mobile): highlighted code fences scroll sideways again on iOS Wrapping the fallback code block's horizontal ScrollView in the long-press menu's Pressable stopped it panning on iOS: React Native disables a ScrollView's interaction while an ancestor view is the JS responder. The menu and its Pressable now sit inside the scroller around the code, so panning works again and a long-press still opens Copy / Copy as Markdown. Co-Authored-By: Claude Opus 5.5 --- .../src/native/HighlightedCodeBlock.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index 16cdd9ddc2dc6..5d61b2de20eaf 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Pressable, ScrollView, Text, View } from "react-native"; +import { Pressable, ScrollView, Text } from "react-native"; import type { MarkdownStyle } from "react-native-enriched-markdown"; import { Menu } from "../components/ui/Menu"; @@ -121,7 +121,21 @@ export function HighlightedCodeBlock(props: { }) ?? code} ); - const padding = { padding: codeStyle.padding }; + // Inside the scroller: on iOS an ancestor JS responder disables a ScrollView's panning. + const content = ( + { + copyTextWithHaptic( + nativeEvent.event === "copy-markdown" ? fencedCodeMarkdown(code, props.fence) : code, + ); + }} + > + {text} + + ); return ( } > - { - copyTextWithHaptic( - nativeEvent.event === "copy-markdown" ? fencedCodeMarkdown(code, props.fence) : code, - ); - }} - > - - {wrapped ? ( - {text} - ) : ( - - {text} - - )} - - + {wrapped ? ( + content + ) : ( + + {content} + + )} ); } From 0f0c65277170275015ac8cd050a22ceba6b88163 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 08:39:57 +0000 Subject: [PATCH 07/13] test(mobile): pin which renderer each code fence gets The mermaid/math-before-fallback precedence lived only inside codeBlockElement, so nothing caught the @pierre/highlights 0.5.0 regression where mermaid fences fell through to the code fallback. Move that decision into codeFenceRenderer in codeBlockMarkdown.ts, switch on it in codeBlockElement with no behaviour change, and table-test it. Note that the complete prop is always true in T3 today. AI-assisted; verified with the tests and tools listed above. --- .../src/native/EnrichedMarkdownText.tsx | 13 ++--- .../src/native/HighlightedCodeBlock.tsx | 40 +--------------- .../src/native/codeBlockMarkdown.test.ts | 19 +++++++- apps/mobile/src/native/codeBlockMarkdown.ts | 47 +++++++++++++++++++ 4 files changed, 73 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 56d313c13dfba..86bf8021bdd0f 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -20,7 +20,8 @@ import { type MarkdownLinkOccurrence, type MarkdownStyle, } from "react-native-enriched-markdown"; -import { fallbackHighlightLanguage, HighlightedCodeBlock } from "./HighlightedCodeBlock"; +import { codeFenceRenderer } from "./codeBlockMarkdown"; +import { HighlightedCodeBlock } from "./HighlightedCodeBlock"; import { MermaidCodeBlock } from "./MermaidCodeBlock"; import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; @@ -179,12 +180,13 @@ function codeBlockElement( mathRenderingEnabled: boolean, scheme: HighlightScheme, ): ReactElement | undefined { - if (block.language === "mermaid") { + const renderer = codeFenceRenderer(block.language, mathRenderingEnabled); + if (renderer.kind === "mermaid") { return block.isComplete ? ( ) : undefined; } - if (block.language === "math" && mathRenderingEnabled) { + if (renderer.kind === "math") { return block.isComplete ? ( ) : undefined; } - const language = fallbackHighlightLanguage(block.language); - if (language && markdownStyle.codeBlock) { + if (renderer.kind === "highlight" && markdownStyle.codeBlock) { return ( > = { cc: "C++", @@ -64,20 +34,12 @@ const CODE_MENU_ACTIONS = [ { id: "copy-markdown", title: "Copy as Markdown", image: "doc.text" }, ]; -/** The highlights language for a fence enriched-markdown leaves plain, or null to keep its block. */ -export function fallbackHighlightLanguage(fence: string): string | null { - const name = fence.toLowerCase(); - if (NATIVE_HIGHLIGHT_FENCES.has(name)) return null; - const language = resolveHighlightLanguage({ language: name }); - return language === "text" ? null : language; -} - /** A fence in a language enriched-markdown cannot colour, drawn like its native block. */ export function HighlightedCodeBlock(props: { readonly code: string; readonly fence: string; readonly language: string; - /** False only while the library withholds an unclosed fence (`streamingAnimation`); highlighting waits for it. */ + /** False only while the library withholds an unclosed fence (`streamingAnimation`, which T3 never enables, so always true today); highlighting waits for it. */ readonly complete: boolean; readonly scheme: HighlightScheme; readonly textStyle: NativeMarkdownTextStyle; diff --git a/apps/mobile/src/native/codeBlockMarkdown.test.ts b/apps/mobile/src/native/codeBlockMarkdown.test.ts index 24031e78d3f7a..1b254ed420ed8 100644 --- a/apps/mobile/src/native/codeBlockMarkdown.test.ts +++ b/apps/mobile/src/native/codeBlockMarkdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { fencedCodeMarkdown } from "./codeBlockMarkdown"; +import { codeFenceRenderer, fencedCodeMarkdown } from "./codeBlockMarkdown"; describe("fencedCodeMarkdown", () => { it.each([ @@ -11,3 +11,20 @@ describe("fencedCodeMarkdown", () => { expect(fencedCodeMarkdown(code, language)).toBe(expected); }); }); + +describe("codeFenceRenderer", () => { + it.each([ + ["mermaid", true, { kind: "mermaid" }], + ["math", true, { kind: "math" }], + ["math", false, { kind: "native" }], + ["kotlin", true, { kind: "highlight", language: "kotlin" }], + ["KOTLIN", true, { kind: "highlight", language: "kotlin" }], + ["diff", true, { kind: "highlight", language: "diff" }], + ["ts", true, { kind: "native" }], + ["python", true, { kind: "native" }], + ["", true, { kind: "native" }], + ["text", true, { kind: "native" }], + ])("draws %j with math rendering %s as %j", (fence, mathRenderingEnabled, expected) => { + expect(codeFenceRenderer(fence, mathRenderingEnabled)).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/native/codeBlockMarkdown.ts b/apps/mobile/src/native/codeBlockMarkdown.ts index 9b06066bcb762..b4be181a2ba8f 100644 --- a/apps/mobile/src/native/codeBlockMarkdown.ts +++ b/apps/mobile/src/native/codeBlockMarkdown.ts @@ -1,3 +1,34 @@ +import { resolveHighlightLanguage } from "../lib/highlightLanguage"; + +// Fences enriched-markdown colours itself: cpp/highlight/CodeBlockLanguages.cpp entries with a default grammar. +const NATIVE_HIGHLIGHT_FENCES = new Set([ + "bash", + "c", + "css", + "go", + "golang", + "html", + "java", + "javascript", + "js", + "json", + "jsx", + "markdown", + "md", + "py", + "python", + "rs", + "rust", + "sh", + "shell", + "ts", + "tsx", + "typescript", + "yaml", + "yml", + "zsh", +]); + /** A fenced code block for `code`, with a backtick fence longer than any backtick run inside it. */ export function fencedCodeMarkdown(code: string, language: string): string { let longestRun = 0; @@ -5,3 +36,19 @@ export function fencedCodeMarkdown(code: string, language: string): string { const fence = "`".repeat(Math.max(3, longestRun + 1)); return `${fence}${language}\n${code}\n${fence}`; } + +type CodeFenceRenderer = + | { readonly kind: "mermaid" } + | { readonly kind: "math" } + | { readonly kind: "highlight"; readonly language: string } + | { readonly kind: "native" }; + +/** Mermaid and math fences first: highlights knows `mermaid` too, and would claim it as code. */ +export function codeFenceRenderer(fence: string, mathRenderingEnabled: boolean): CodeFenceRenderer { + if (fence === "mermaid") return { kind: "mermaid" }; + if (fence === "math" && mathRenderingEnabled) return { kind: "math" }; + const name = fence.toLowerCase(); + if (NATIVE_HIGHLIGHT_FENCES.has(name)) return { kind: "native" }; + const language = resolveHighlightLanguage({ language: name }); + return language === "text" ? { kind: "native" } : { kind: "highlight", language }; +} From 6c54d4a103fe0fa0af237570bc0cad6a9107e593 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 08:51:30 +0000 Subject: [PATCH 08/13] docs(mobile): name the file the fence display names come from AI-assisted; verified with the tests and tools listed above. --- apps/mobile/src/native/HighlightedCodeBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index 76bb62f40d1c0..01c79a8f111db 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -10,7 +10,7 @@ import { CodeBlockFrame, CodeBlockHeaderButton } from "./CodeBlockFrame"; import { fencedCodeMarkdown } from "./codeBlockMarkdown"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; -// The same file's header names that are not the fence capitalized. +// Header names from enriched-markdown's CodeBlockLanguages.cpp that are not the fence capitalized. const FENCE_DISPLAY_NAMES: Readonly> = { cc: "C++", cpp: "C++", From 6e5d2a0d70e3138da2d89e89ab6092bc05ecb6dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 09:30:17 +0000 Subject: [PATCH 09/13] fix(mobile): Android menus stay on screen when their anchor is taller than the view With the keyboard open, the in-window Android menu placed itself above or below its anchor. When neither side had room (a code block taller than the screen), maxHeight went negative and the menu landed off-screen. Vertical placement is now the pure deriveMenuVerticalPlacement, beside the horizontal one. Anchors with room on one side open exactly as before; when neither side fits two menu rows, the menu overlaps the anchor inside the keyboard-free area. AI-assisted; verified with the tests and tools listed above. --- .../ui/AndroidAnchoredMenu.android.tsx | 33 ++++++++++--------- apps/mobile/src/lib/menu-placement.test.ts | 27 +++++++++++++++ apps/mobile/src/lib/menu-placement.ts | 28 ++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/menu-placement.test.ts diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx index 369fcf19ef55a..bf08dc826a892 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx @@ -6,7 +6,10 @@ import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { useHardwareBack } from "../../lib/backHandler"; -import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; +import { + deriveMenuHorizontalPlacement, + deriveMenuVerticalPlacement, +} from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; @@ -41,7 +44,7 @@ export type AndroidAnchoredMenuProps = { }; export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { - const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); + const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); const [rootHeight, setRootHeight] = useState(null); @@ -110,16 +113,19 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { margin: SCREEN_MARGIN, hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width }, }); - // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. - const usableBottom = - overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); - const spaceBelow = + const verticalPlacement = local === null || overlay === null - ? 0 - : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; - const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; - const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; - const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + ? null + : deriveMenuVerticalPlacement({ + anchorY: local.y, + anchorHeight: local.height, + overlayHeight: overlay.height, + // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. + keyboardInset: keyboardVisible ? keyboardHeight : 0, + gap: ANCHOR_GAP, + margin: SCREEN_MARGIN, + minHeight: 2 * menuItemHeight + 14 * scale, + }); const placeable = local !== null && rootHeight !== null; const onPressItem = (action: MenuAction) => { @@ -185,12 +191,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { className="absolute overflow-hidden bg-card-alt shadow-md" style={{ left, - maxHeight, width: menuWidth, borderRadius: 4 * scale, - ...(opensDown - ? { top: local.y + local.height + ANCHOR_GAP } - : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), + ...verticalPlacement, }} > {/* Compose DropdownMenu takes focus; in-window editor menus preserve the keyboard. */} diff --git a/apps/mobile/src/lib/menu-placement.test.ts b/apps/mobile/src/lib/menu-placement.test.ts new file mode 100644 index 0000000000000..725a5c3ad879f --- /dev/null +++ b/apps/mobile/src/lib/menu-placement.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveMenuVerticalPlacement } from "./menu-placement"; + +const base = { gap: 6, margin: 12, minHeight: 110 }; + +describe("deriveMenuVerticalPlacement", () => { + it.each([ + { + name: "anchor taller than the keyboard-free area stays on screen", + input: { anchorY: -100, anchorHeight: 1000, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 476, top: 12 }, + }, + { + name: "room below opens down", + input: { anchorY: 100, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 342, top: 146 }, + }, + { + name: "room above opens up", + input: { anchorY: 400, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 }, + expected: { maxHeight: 382, bottom: 406 }, + }, + ])("$name", ({ input, expected }) => { + expect(deriveMenuVerticalPlacement({ ...base, ...input })).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/lib/menu-placement.ts b/apps/mobile/src/lib/menu-placement.ts index 8bd560e6a446b..eb7262a41eaa6 100644 --- a/apps/mobile/src/lib/menu-placement.ts +++ b/apps/mobile/src/lib/menu-placement.ts @@ -23,3 +23,31 @@ export function deriveMenuHorizontalPlacement(input: { const left = Math.min(Math.max(preferredLeft, start + input.margin), end - width - input.margin); return { left, width }; } + +/** In the overlay's coordinates; `bottom` is measured up from the overlay's bottom edge. */ +export function deriveMenuVerticalPlacement(input: { + readonly anchorY: number; + readonly anchorHeight: number; + readonly overlayHeight: number; + readonly keyboardInset: number; + readonly gap: number; + readonly margin: number; + readonly minHeight: number; +}) { + const usableBottom = input.overlayHeight - input.keyboardInset; + const spaceBelow = usableBottom - (input.anchorY + input.anchorHeight) - input.gap - input.margin; + const spaceAbove = input.anchorY - input.gap - input.margin; + const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; + const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + if (maxHeight < input.minHeight) { + // Neither side fits (e.g. a code block taller than the screen): overlap the anchor instead. + const top = Math.max( + input.margin, + Math.min(input.anchorY, usableBottom - input.margin - input.minHeight), + ); + return { maxHeight: Math.min(usableBottom - input.margin - top, 480), top }; + } + return opensDown + ? { maxHeight, top: input.anchorY + input.anchorHeight + input.gap } + : { maxHeight, bottom: input.overlayHeight - input.anchorY + input.gap }; +} From 63069af9644a7b85993ad6fee73de314efac088e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 09:46:54 +0000 Subject: [PATCH 10/13] fix(mobile): first long-press on a scrolled code block keeps the keyboard and opens Copy AI-assisted; verified with the tests and tools listed above. --- apps/mobile/src/native/HighlightedCodeBlock.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index 01c79a8f111db..a8a87ec2a2828 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -123,7 +123,12 @@ export function HighlightedCodeBlock(props: { {wrapped ? ( content ) : ( - + {content} )} From c6afa44bc640c3b6ab0173a87d2dd185c358bb2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 11:42:44 +0000 Subject: [PATCH 11/13] docs(mobile): say what keeping taps on the code scroller does AI-assisted; verified with the tests and tools listed above. --- apps/mobile/src/native/HighlightedCodeBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/native/HighlightedCodeBlock.tsx b/apps/mobile/src/native/HighlightedCodeBlock.tsx index a8a87ec2a2828..b0c981d95e3fc 100644 --- a/apps/mobile/src/native/HighlightedCodeBlock.tsx +++ b/apps/mobile/src/native/HighlightedCodeBlock.tsx @@ -125,7 +125,7 @@ export function HighlightedCodeBlock(props: { ) : ( From 06c360d934daf28b59cda4efc7616408f2458e32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 12:48:43 +0000 Subject: [PATCH 12/13] fix(mobile): code block headers grow with large text instead of clipping the label AI-assisted; verified with the tests and tools listed above. --- apps/mobile/src/native/CodeBlockFrame.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/native/CodeBlockFrame.tsx b/apps/mobile/src/native/CodeBlockFrame.tsx index 335a1d3183b6a..1c80e759d4dbd 100644 --- a/apps/mobile/src/native/CodeBlockFrame.tsx +++ b/apps/mobile/src/native/CodeBlockFrame.tsx @@ -86,7 +86,7 @@ const styles = StyleSheet.create({ overflow: "hidden", }, header: { - height: 32, + minHeight: 32, paddingHorizontal: 12, flexDirection: "row", alignItems: "center", From e0bc83b8dbce439dae822dfbe0152f7328769ed6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 19:23:52 +0000 Subject: [PATCH 13/13] refactor(mobile): drop the Android menu's duplicate root height AI-assisted; verified with the tests and tools listed above. --- .../src/components/ui/AndroidAnchoredMenu.android.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx index bf08dc826a892..a17e48c2604c7 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx @@ -47,7 +47,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); - const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); const windowHinge = use(WindowHingeContext); // State, not a ref: `open` reaches render-prop children, and the compiler @@ -61,7 +60,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { setAnchor(null); setPath([]); setOverlay(null); - setRootHeight(null); }; const open = () => { @@ -73,7 +71,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); - setRootHeight(height); }); }; @@ -126,7 +123,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { margin: SCREEN_MARGIN, minHeight: 2 * menuItemHeight + 14 * scale, }); - const placeable = local !== null && rootHeight !== null; const onPressItem = (action: MenuAction) => { if ((action.subactions?.length ?? 0) > 0) { @@ -173,7 +169,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { onLayout={measureOverlay} > - {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( + {local === null ? null : !anchor.keyboardWasVisible ? (