authorgravatar for n.anisimov.23@gmail.comNick Anisimov <n.anisimov.23@gmail.com> 2026-08-12 13:09:51+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 14:39:51+05:30
loge1378a1f4d6a78d51c2d583997121f6adc0ad6cc
tree39eb80d949812de28b4387fc336263ff29d7b8be
parent5a84614809b6e853b872f9e57ff4b97e9df5df02
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep ordered lists inside user bubbles (#6154)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

3 files changed, 89 insertions(+), 9 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+5-1
......@@ -104,6 +104,10 @@ import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
104104import { useAssetUrl } from "../../state/assets";
105105import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
106106
107const WIDE_MARKDOWN_BLOCK_OPTIONS = {
108 includeOrderedLists: Platform.OS === "android",
109} as const;
110
107111const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
108112 hour: "numeric",
109113 minute: "2-digit",
......@@ -888,7 +892,7 @@ function renderFeedEntry(
888892 // children during the unclamped pass and never moves them once the width
889893 // is clamped, so the paragraphs around the block end up drawn on top of
890894 // each other. Pinning the width removes that pass.
891 const hasWideBlock = hasWideMarkdownBlock(message.text);
895 const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS);
892896 const assistantTurnStillInProgress =
893897 message.role === "assistant" &&
894898 props.unsettledTurnId !== null &&
apps/mobile/src/lib/wideMarkdownBlocks.test.ts+24
......@@ -17,6 +17,30 @@ describe("hasWideMarkdownBlock", () => {
1717 expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true);
1818 });
1919
20 it("detects top-level and blockquoted ordered-list markers", () => {
21 expect(hasWideMarkdownBlock("1. One\n2. Two\n3. Three\n4. Four\n5. Five")).toBe(true);
22 expect(hasWideMarkdownBlock("before\n3) Three")).toBe(true);
23 expect(hasWideMarkdownBlock("> 1. One\n> 2. Two")).toBe(true);
24 expect(hasWideMarkdownBlock("> > 3) Three")).toBe(true);
25 });
26
27 it("detects nested ordered lists without treating indented code as a list", () => {
28 expect(hasWideMarkdownBlock("- Parent\n 1. Child\n 2. Child")).toBe(true);
29 expect(hasWideMarkdownBlock("> - Parent\n> 1. Child")).toBe(true);
30 expect(hasWideMarkdownBlock(" 1. indented code")).toBe(false);
31 expect(hasWideMarkdownBlock(" - code-like bullet\n 1. indented code")).toBe(false);
32 });
33
34 it("can limit ordered-list width pinning to Android", () => {
35 const orderedList = "1. One\n2. Two";
36 expect(hasWideMarkdownBlock(orderedList, { includeOrderedLists: true })).toBe(true);
37 expect(hasWideMarkdownBlock(orderedList, { includeOrderedLists: false })).toBe(false);
38 expect(hasWideMarkdownBlock("```\ncode\n```", { includeOrderedLists: false })).toBe(true);
39 expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |", { includeOrderedLists: false })).toBe(
40 true,
41 );
42 });
43
2044 it("detects GFM tables", () => {
2145 expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true);
2246 expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true);
apps/mobile/src/lib/wideMarkdownBlocks.ts+60-8
......@@ -1,9 +1,10 @@
11/**
2 * Detects markdown that the JS renderer draws as a standalone block View
3 * wrapping a horizontal ScrollView — fenced code blocks and GFM tables.
2 * Detects markdown that the JS renderer draws as a block requiring a definite
3 * user-bubble width — fenced code blocks, GFM tables, and ordered lists.
44 *
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
5 * Fenced code blocks and tables report an intrinsic width equal to their
6 * widest line, which is effectively unbounded. A user bubble sizes itself
7 * from its content
78 * (`maxWidth` with no `width`), so Android lays the bubble's children out
89 * during the unclamped intrinsic pass — where the surrounding paragraphs
910 * collapse to a single line — and never repositions them once the width is
......@@ -12,12 +13,57 @@
1213 * intrinsic pass entirely, which is the same reason review-comment bubbles
1314 * already carry an explicit width.
1415 *
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.
16 * Ordered lists hit the same Android layout bug because each item contains a
17 * flexing content column inside a shrink-to-fit row.
18 *
19 * A four-space ordered marker is only treated as a list when the preceding
20 * non-empty line is a less-indented list item. This keeps standalone indented
21 * code out of the heuristic while covering nested lists.
1822 */
1923
2024const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;
25// Trades some precision for a simple check, favoring false positives over false
26// negatives: list-shaped paragraph may get a wider bubble
27const ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/;
28const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/;
29const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/;
30const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/;
31
32export interface WideMarkdownBlockOptions {
33 readonly includeOrderedLists?: boolean;
34}
35
36function stripBlockquotePrefixes(line: string): string {
37 let content = line;
38 while (BLOCKQUOTE_PREFIX.test(content)) {
39 content = content.replace(BLOCKQUOTE_PREFIX, "");
40 }
41 return content;
42}
43
44function hasOrderedListItem(text: string): boolean {
45 let previousNonEmptyLine: string | null = null;
46
47 for (const rawLine of text.split("\n")) {
48 const line = stripBlockquotePrefixes(rawLine);
49 if (ORDERED_LIST_ITEM.test(line)) {
50 return true;
51 }
52
53 const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line);
54 const parentMatch =
55 previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine);
56 if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) {
57 return true;
58 }
59
60 if (line.trim().length > 0) {
61 previousNonEmptyLine = line;
62 }
63 }
64
65 return false;
66}
2167
2268function isTableDelimiterRow(line: string): boolean {
2369 const trimmed = line.trim();
......@@ -27,10 +73,16 @@ function isTableDelimiterRow(line: string): boolean {
2773 return /^[|\-: \t]+$/.test(trimmed);
2874}
2975
30export function hasWideMarkdownBlock(text: string): boolean {
76export function hasWideMarkdownBlock(
77 text: string,
78 options: WideMarkdownBlockOptions = {},
79): boolean {
3180 if (FENCED_CODE_BLOCK.test(text)) {
3281 return true;
3382 }
83 if (options.includeOrderedLists !== false && hasOrderedListItem(text)) {
84 return true;
85 }
3486 if (!text.includes("|")) {
3587 return false;
3688 }