From 5b44d8448065f83e653ec8bfe8f481051fd38696 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Sat, 8 Aug 2026 01:05:02 +0200 Subject: [PATCH] fix(mobile): stop Android user bubbles with code blocks from overlapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android a user message containing a fenced code block draws its paragraphs on top of each other inside a hugely over-tall bubble, and the overflow paints over the following rows in the feed. The bubble sizes itself from its content (`maxWidth`, no `width`), so a child whose intrinsic width is unbounded — the code block's horizontal ScrollView — forces a clamp. Android positions the bubble's children during the unclamped intrinsic pass, where the surrounding paragraphs collapse to a single line, and never repositions them once the width is clamped back to `maxWidth`. Measured on a Pixel 8, the paragraph is laid out 612px tall while the code block that follows it is placed 28px below the paragraph's *top* — an overlap of 584px. Pinning the bubble's width removes the intrinsic pass, which is the same reason review-comment bubbles already carry an explicit width. Applies to GFM tables too, which use the same horizontally scrollable renderer. iOS is unaffected: it renders markdown through the native SelectableMarkdownText view, which has no nested block children. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 13 ++++++- .../mobile/src/lib/wideMarkdownBlocks.test.ts | 24 ++++++++++++ apps/mobile/src/lib/wideMarkdownBlocks.ts | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/wideMarkdownBlocks.test.ts create mode 100644 apps/mobile/src/lib/wideMarkdownBlocks.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..7933e4ca601 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -876,6 +877,12 @@ function renderFeedEntry( const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" {message.text.trim().length > 0 ? ( diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts new file mode 100644 index 00000000000..9f0bcaee325 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasWideMarkdownBlock } from "./wideMarkdownBlocks"; + +describe("hasWideMarkdownBlock", () => { + it("ignores prose, inline code, and emphasis", () => { + expect(hasWideMarkdownBlock("just a message")).toBe(false); + expect(hasWideMarkdownBlock("I found it in `secteurs_intervention` earlier")).toBe(false); + expect(hasWideMarkdownBlock("a | b in a sentence")).toBe(false); + expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false); + }); + + it("detects fenced code blocks", () => { + expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true); + expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true); + expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true); + expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); + }); + + it("detects GFM tables", () => { + expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); + expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts new file mode 100644 index 00000000000..801d826df54 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -0,0 +1,38 @@ +/** + * Detects markdown that the JS renderer draws as a standalone block View + * wrapping a horizontal ScrollView — fenced code blocks and GFM tables. + * + * Those blocks report an intrinsic width equal to their widest line, which is + * effectively unbounded. A user bubble sizes itself from its content + * (`maxWidth` with no `width`), so Android lays the bubble's children out + * during the unclamped intrinsic pass — where the surrounding paragraphs + * collapse to a single line — and never repositions them once the width is + * clamped back to `maxWidth`. The result is siblings drawn on top of each + * other inside an over-tall bubble. Pinning the bubble's width removes the + * intrinsic pass entirely, which is the same reason review-comment bubbles + * already carry an explicit width. + * + * Indented (four-space) code blocks are deliberately not detected: they are + * vanishingly rare in chat input and the check would fire on ordinary nested + * list continuations. + */ + +const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; + +function isTableDelimiterRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes("|") || !trimmed.includes("-")) { + return false; + } + return /^[|\-: \t]+$/.test(trimmed); +} + +export function hasWideMarkdownBlock(text: string): boolean { + if (FENCED_CODE_BLOCK.test(text)) { + return true; + } + if (!text.includes("|")) { + return false; + } + return text.split("\n").some(isTableDelimiterRow); +}