authorgravatar for rodrigobrechard@gmail.comRodrigo Brechard <rodrigobrechard@gmail.com> 2026-08-10 12:36:08+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 12:36:08+02:00
logd440442db6cd1c45696ba551a71ee6575cc23f37
tree04e8c36021c4fb7e68ddf4cbba9e0d15a48810e8
parent67ef189d8b7b68c3096350db404bd649704ec6f2
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stop Android user bubbles with code blocks from overlapping (#5659)

Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

3 files changed, 74 insertions(+), 1 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+12-1
......@@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani
4949import { useThemeColor } from "../../lib/useThemeColor";
5050import { useFontFamily } from "../../lib/useFontFamily";
5151import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
52import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
5253import {
5354 hasNativeSelectableMarkdownText,
5455 SelectableMarkdownText,
......@@ -876,6 +877,12 @@ function renderFeedEntry(
876877 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
877878 const attachments = message.attachments ?? [];
878879 const hasReviewCommentContext = message.text.includes("<review_comment");
880 // A bubble that sizes itself from its content cannot lay out a block whose
881 // intrinsic width overflows `maxWidth`: Android positions the bubble's
882 // children during the unclamped pass and never moves them once the width
883 // is clamped, so the paragraphs around the block end up drawn on top of
884 // each other. Pinning the width removes that pass.
885 const hasWideBlock = hasWideMarkdownBlock(message.text);
879886 const assistantTurnStillInProgress =
880887 message.role === "assistant" &&
881888 props.unsettledTurnId !== null &&
......@@ -898,7 +905,11 @@ function renderFeedEntry(
898905 style={{
899906 backgroundColor: userBubbleColor,
900907 maxWidth: props.userBubbleMaxWidth,
901 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
908 ...(hasReviewCommentContext
909 ? { width: props.reviewCommentBubbleWidth }
910 : hasWideBlock
911 ? { width: props.userBubbleMaxWidth }
912 : null),
902913 }}
903914 >
904915 {message.text.trim().length > 0 ? (
apps/mobile/src/lib/wideMarkdownBlocks.test.ts created+24
......@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { hasWideMarkdownBlock } from "./wideMarkdownBlocks";
4
5describe("hasWideMarkdownBlock", () => {
6 it("ignores prose, inline code, and emphasis", () => {
7 expect(hasWideMarkdownBlock("just a message")).toBe(false);
8 expect(hasWideMarkdownBlock("I found it in `secteurs_intervention` earlier")).toBe(false);
9 expect(hasWideMarkdownBlock("a | b in a sentence")).toBe(false);
10 expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false);
11 });
12
13 it("detects fenced code blocks", () => {
14 expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true);
15 expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true);
16 expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true);
17 expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true);
18 });
19
20 it("detects GFM tables", () => {
21 expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true);
22 expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true);
23 });
24});
apps/mobile/src/lib/wideMarkdownBlocks.ts created+38
......@@ -0,0 +1,38 @@
1/**
2 * Detects markdown that the JS renderer draws as a standalone block View
3 * wrapping a horizontal ScrollView — fenced code blocks and GFM tables.
4 *
5 * Those blocks report an intrinsic width equal to their widest line, which is
6 * effectively unbounded. A user bubble sizes itself from its content
7 * (`maxWidth` with no `width`), so Android lays the bubble's children out
8 * during the unclamped intrinsic pass — where the surrounding paragraphs
9 * collapse to a single line — and never repositions them once the width is
10 * clamped back to `maxWidth`. The result is siblings drawn on top of each
11 * other inside an over-tall bubble. Pinning the bubble's width removes the
12 * intrinsic pass entirely, which is the same reason review-comment bubbles
13 * already carry an explicit width.
14 *
15 * Indented (four-space) code blocks are deliberately not detected: they are
16 * vanishingly rare in chat input and the check would fire on ordinary nested
17 * list continuations.
18 */
19
20const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;
21
22function isTableDelimiterRow(line: string): boolean {
23 const trimmed = line.trim();
24 if (!trimmed.includes("|") || !trimmed.includes("-")) {
25 return false;
26 }
27 return /^[|\-: \t]+$/.test(trimmed);
28}
29
30export function hasWideMarkdownBlock(text: string): boolean {
31 if (FENCED_CODE_BLOCK.test(text)) {
32 return true;
33 }
34 if (!text.includes("|")) {
35 return false;
36 }
37 return text.split("\n").some(isTableDelimiterRow);
38}