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";...@@ -104,6 +104,10 @@ import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
104import { useAssetUrl } from "../../state/assets";104import { useAssetUrl } from "../../state/assets";
105import { resolveWorkspaceRelativeFilePath } from "../files/filePath";105import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
106106
107const WIDE_MARKDOWN_BLOCK_OPTIONS = {
108 includeOrderedLists: Platform.OS === "android",
109} as const;
110
107const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {111const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
108 hour: "numeric",112 hour: "numeric",
109 minute: "2-digit",113 minute: "2-digit",
...@@ -888,7 +892,7 @@ function renderFeedEntry(...@@ -888,7 +892,7 @@ function renderFeedEntry(
888 // children during the unclamped pass and never moves them once the width892 // children during the unclamped pass and never moves them once the width
889 // is clamped, so the paragraphs around the block end up drawn on top of893 // is clamped, so the paragraphs around the block end up drawn on top of
890 // each other. Pinning the width removes that pass.894 // each other. Pinning the width removes that pass.
891 const hasWideBlock = hasWideMarkdownBlock(message.text);895 const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS);
892 const assistantTurnStillInProgress =896 const assistantTurnStillInProgress =
893 message.role === "assistant" &&897 message.role === "assistant" &&
894 props.unsettledTurnId !== null &&898 props.unsettledTurnId !== null &&
apps/mobile/src/lib/wideMarkdownBlocks.test.ts+24
...@@ -17,6 +17,30 @@ describe("hasWideMarkdownBlock", () => {...@@ -17,6 +17,30 @@ describe("hasWideMarkdownBlock", () => {
17 expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true);17 expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true);
18 });18 });
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
20 it("detects GFM tables", () => {44 it("detects GFM tables", () => {
21 expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true);45 expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true);
22 expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true);46 expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true);
apps/mobile/src/lib/wideMarkdownBlocks.ts+60-8
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1/**1/**
2 * Detects markdown that the JS renderer draws as a standalone block View2 * Detects markdown that the JS renderer draws as a block requiring a definite
3 * wrapping a horizontal ScrollView — fenced code blocks and GFM tables.3 * user-bubble width — fenced code blocks, GFM tables, and ordered lists.
4 *4 *
5 * Those blocks report an intrinsic width equal to their widest line, which is5 * Fenced code blocks and tables report an intrinsic width equal to their
6 * effectively unbounded. A user bubble sizes itself from its content6 * widest line, which is effectively unbounded. A user bubble sizes itself
7 * from its content
7 * (`maxWidth` with no `width`), so Android lays the bubble's children out8 * (`maxWidth` with no `width`), so Android lays the bubble's children out
8 * during the unclamped intrinsic pass — where the surrounding paragraphs9 * during the unclamped intrinsic pass — where the surrounding paragraphs
9 * collapse to a single line — and never repositions them once the width is10 * collapse to a single line — and never repositions them once the width is
...@@ -12,12 +13,57 @@...@@ -12,12 +13,57 @@
12 * intrinsic pass entirely, which is the same reason review-comment bubbles13 * intrinsic pass entirely, which is the same reason review-comment bubbles
13 * already carry an explicit width.14 * already carry an explicit width.
14 *15 *
15 * Indented (four-space) code blocks are deliberately not detected: they are16 * Ordered lists hit the same Android layout bug because each item contains a
16 * vanishingly rare in chat input and the check would fire on ordinary nested17 * flexing content column inside a shrink-to-fit row.
17 * list continuations.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.
18 */22 */
1923
20const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;24const 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
22function isTableDelimiterRow(line: string): boolean {68function isTableDelimiterRow(line: string): boolean {
23 const trimmed = line.trim();69 const trimmed = line.trim();
...@@ -27,10 +73,16 @@ function isTableDelimiterRow(line: string): boolean {...@@ -27,10 +73,16 @@ function isTableDelimiterRow(line: string): boolean {
27 return /^[|\-: \t]+$/.test(trimmed);73 return /^[|\-: \t]+$/.test(trimmed);
28}74}
2975
30export function hasWideMarkdownBlock(text: string): boolean {76export function hasWideMarkdownBlock(
77 text: string,
78 options: WideMarkdownBlockOptions = {},
79): boolean {
31 if (FENCED_CODE_BLOCK.test(text)) {80 if (FENCED_CODE_BLOCK.test(text)) {
32 return true;81 return true;
33 }82 }
83 if (options.includeOrderedLists !== false && hasOrderedListItem(text)) {
84 return true;
85 }
34 if (!text.includes("|")) {86 if (!text.includes("|")) {
35 return false;87 return false;
36 }88 }