| author | |
| committer | |
| log | e1378a1f4d6a78d51c2d583997121f6adc0ad6cc |
| tree | 39eb80d949812de28b4387fc336263ff29d7b8be |
| parent | 5a84614809b6e853b872f9e57ff4b97e9df5df02 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 104 | import { useAssetUrl } from "../../state/assets"; | 104 | import { useAssetUrl } from "../../state/assets"; |
| 105 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 105 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 106 | 106 | ||
| 107 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { | ||
| 108 | includeOrderedLists: Platform.OS === "android", | ||
| 109 | } as const; | ||
| 110 | |||
| 107 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | 111 | const 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 width | 892 | // 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 of | 893 | // 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 | }); |
| 19 | 19 | ||
| 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 View | 2 | * 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 is | 5 | * Fenced code blocks and tables report an intrinsic width equal to their |
| 6 | * effectively unbounded. A user bubble sizes itself from its content | 6 | * 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 out | 8 | * (`maxWidth` with no `width`), so Android lays the bubble's children out |
| 8 | * during the unclamped intrinsic pass — where the surrounding paragraphs | 9 | * during the unclamped intrinsic pass — where the surrounding paragraphs |
| 9 | * collapse to a single line — and never repositions them once the width is | 10 | * 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 bubbles | 13 | * 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 are | 16 | * 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 nested | 17 | * 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 | */ |
| 19 | 23 | ||
| 20 | const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; | 24 | const 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 | ||
| 27 | const ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/; | ||
| 28 | const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/; | ||
| 29 | const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/; | ||
| 30 | const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/; | ||
| 31 | |||
| 32 | export interface WideMarkdownBlockOptions { | ||
| 33 | readonly includeOrderedLists?: boolean; | ||
| 34 | } | ||
| 35 | |||
| 36 | function 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 | |||
| 44 | function 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 | } | ||
| 21 | 67 | ||
| 22 | function isTableDelimiterRow(line: string): boolean { | 68 | function 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 | } |
| 29 | 75 | ||
| 30 | export function hasWideMarkdownBlock(text: string): boolean { | 76 | export 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 | } |