| author | |
| committer | |
| log | d440442db6cd1c45696ba551a71ee6575cc23f37 |
| tree | 04e8c36021c4fb7e68ddf4cbba9e0d15a48810e8 |
| parent | 67ef189d8b7b68c3096350db404bd649704ec6f2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 49 | 49 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 50 | 50 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 51 | 51 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 52 | import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; | |
| 52 | 53 | import { |
| 53 | 54 | hasNativeSelectableMarkdownText, |
| 54 | 55 | SelectableMarkdownText, |
| ... | ... | @@ -876,6 +877,12 @@ function renderFeedEntry( |
| 876 | 877 | const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); |
| 877 | 878 | const attachments = message.attachments ?? []; |
| 878 | 879 | 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); | |
| 879 | 886 | const assistantTurnStillInProgress = |
| 880 | 887 | message.role === "assistant" && |
| 881 | 888 | props.unsettledTurnId !== null && |
| ... | ... | @@ -898,7 +905,11 @@ function renderFeedEntry( |
| 898 | 905 | style={{ |
| 899 | 906 | backgroundColor: userBubbleColor, |
| 900 | 907 | maxWidth: props.userBubbleMaxWidth, |
| 901 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), | |
| 908 | ...(hasReviewCommentContext | |
| 909 | ? { width: props.reviewCommentBubbleWidth } | |
| 910 | : hasWideBlock | |
| 911 | ? { width: props.userBubbleMaxWidth } | |
| 912 | : null), | |
| 902 | 913 | }} |
| 903 | 914 | > |
| 904 | 915 | {message.text.trim().length > 0 ? ( |
apps/mobile/src/lib/wideMarkdownBlocks.test.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { hasWideMarkdownBlock } from "./wideMarkdownBlocks"; | |
| 4 | ||
| 5 | describe("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 | ||
| 20 | const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; | |
| 21 | ||
| 22 | function 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 | ||
| 30 | export 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 | } |