Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -876,6 +877,12 @@ function renderFeedEntry(
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
const hasReviewCommentContext = message.text.includes("<review_comment");
// A bubble that sizes itself from its content cannot lay out a block whose
// intrinsic width overflows `maxWidth`: Android positions the bubble's
// children during the unclamped pass and never moves them once the width
// is clamped, so the paragraphs around the block end up drawn on top of
// each other. Pinning the width removes that pass.
const hasWideBlock = hasWideMarkdownBlock(message.text);
const assistantTurnStillInProgress =
message.role === "assistant" &&
props.unsettledTurnId !== null &&
Expand All @@ -898,7 +905,11 @@ function renderFeedEntry(
style={{
backgroundColor: userBubbleColor,
maxWidth: props.userBubbleMaxWidth,
...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
...(hasReviewCommentContext
? { width: props.reviewCommentBubbleWidth }
: hasWideBlock
? { width: props.userBubbleMaxWidth }
: null),
}}
>
{message.text.trim().length > 0 ? (
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/lib/wideMarkdownBlocks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions apps/mobile/src/lib/wideMarkdownBlocks.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading