fix(mobile): allow long-press select/copy of native chat sent text

Outgoing Native Chat bubbles rendered RN Text without selectable, so
iOS/Android long-press never raised selection handles or Copy. Mark
user bubbles and markdown prose selectable so sent and received text
match the platform copy callout.

STA-4983
This commit is contained in:
Brennan Benson
2026-08-20 15:44:54 -07:00
parent 4e058d4a52
commit 25291ea5e7
4 changed files with 133 additions and 42 deletions
@@ -130,3 +130,33 @@ describe('MobileMarkdown file links', () => {
expect(pressables(renderer!)).toHaveLength(0)
})
})
describe('MobileMarkdown text selection', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function renderMarkdown(content: string): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content }))
})
return renderer!
}
it('makes paragraph text selectable for long-press copy', () => {
const tree = renderMarkdown('Hello from the agent')
const texts = tree.root.findAllByType('Text' as never)
expect(texts.length).toBeGreaterThan(0)
expect(texts.every((node) => node.props.selectable === true)).toBe(true)
})
it('makes headings, lists, quotes, and code selectable', () => {
const tree = renderMarkdown('# Title\n\n> quoted\n\n- item\n\n```ts\nconst x = 1\n```')
const texts = tree.root.findAllByType('Text' as never)
expect(texts.length).toBeGreaterThan(0)
expect(texts.every((node) => node.props.selectable === true)).toBe(true)
})
})
+61 -40
View File
@@ -1,4 +1,4 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Fragment, memo, useMemo, type ComponentProps, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, Text, View } from 'react-native'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { styles } from './mobile-markdown-styles'
@@ -29,6 +29,11 @@ type Props = {
onOpenFile?: (pathText: string) => void
}
// RN Text is not selectable by default; chat long-press copy needs the platform handles.
function MarkdownText(props: ComponentProps<typeof Text>): React.JSX.Element {
return <Text {...props} selectable />
}
const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
@@ -64,13 +69,13 @@ function renderTextRun(
return segments.map((segment, segmentIndex) => {
if (segment.type === 'file') {
return (
<Text
<MarkdownText
key={`${keyPrefix}:${segmentIndex}`}
style={styles.link}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</Text>
</MarkdownText>
)
}
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
@@ -105,22 +110,34 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(image[2]!, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(image[2]!, onOpenFile)}
>
{image[1] || 'image'}
</Text>
</MarkdownText>
)
} else if (link) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(link[2]!, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(link[2]!, onOpenFile)}
>
{link[1]}
</Text>
</MarkdownText>
)
} else if (/^https?:\/\//i.test(token)) {
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(url, onOpenFile)}>
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(url, onOpenFile)}
>
{url}
</Text>
</MarkdownText>
)
if (trailing) {
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
@@ -129,38 +146,38 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
parts.push(
<Text
<MarkdownText
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
>
{code}
</Text>
</MarkdownText>
)
} else {
parts.push(
<Text key={key} style={styles.inlineCode}>
<MarkdownText key={key} style={styles.inlineCode}>
{code}
</Text>
</MarkdownText>
)
}
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
<MarkdownText key={key} style={styles.strike}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
<Text key={key} style={styles.bold}>
<MarkdownText key={key} style={styles.bold}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
} else {
parts.push(
<Text key={key} style={styles.italic}>
<MarkdownText key={key} style={styles.italic}>
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
</Text>
</MarkdownText>
)
}
}
@@ -181,7 +198,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
const proseScale = scaled(13)
const listScale = scaled(14)
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
return fallback ? <MarkdownText style={styles.paragraph}>{fallback}</MarkdownText> : null
}
const mermaidSourceOccurrences = new Map<string, number>()
@@ -190,18 +207,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
{blocks.map((block, index) => {
if (block.type === 'heading') {
return (
<Text
<MarkdownText
key={index}
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text, onOpenFile)}
</Text>
</MarkdownText>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text style={styles.quoteText}>{renderInline(block.text, onOpenFile)}</Text>
<MarkdownText style={styles.quoteText}>
{renderInline(block.text, onOpenFile)}
</MarkdownText>
</View>
)
}
@@ -222,8 +241,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
}
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
<Text style={styles.codeText}>{block.text}</Text>
{block.language ? (
<MarkdownText style={styles.codeLanguage}>{block.language}</MarkdownText>
) : null}
<MarkdownText style={styles.codeText}>{block.text}</MarkdownText>
</View>
)
}
@@ -234,10 +255,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
style={styles.imageFrame}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
>
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
<Text style={styles.imageCaption} numberOfLines={1}>
<MarkdownText style={styles.link}>{block.alt || 'Open image'}</MarkdownText>
<MarkdownText style={styles.imageCaption} numberOfLines={1}>
{block.url}
</Text>
</MarkdownText>
</Pressable>
)
}
@@ -251,26 +272,26 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<View style={styles.table}>
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
<MarkdownText key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
{renderInline(header, onOpenFile)}
</Text>
</MarkdownText>
))}
</View>
{visibleRows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} style={styles.tableCell}>
<MarkdownText key={cellIndex} style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</Text>
</MarkdownText>
))}
</View>
))}
{hiddenRows > 0 || hiddenColumns > 0 ? (
<Text style={styles.tableTruncated}>
<MarkdownText style={styles.tableTruncated}>
{hiddenRows > 0 ? `${hiddenRows} more rows` : ''}
{hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''}
{hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''}
</Text>
</MarkdownText>
) : null}
</View>
</ScrollView>
@@ -281,7 +302,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<View key={index} style={styles.list}>
{block.items.map((item, itemIndex) => (
<View key={itemIndex} style={styles.listItem}>
<Text style={styles.listMarker}>
<MarkdownText style={styles.listMarker}>
{item.checked == null
? block.ordered
? `${itemIndex + 1}.`
@@ -289,10 +310,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
: item.checked
? '[x]'
: '[ ]'}
</Text>
<Text style={[styles.listText, listScale]}>
</MarkdownText>
<MarkdownText style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</Text>
</MarkdownText>
</View>
))}
</View>
@@ -302,14 +323,14 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
return <View key={index} style={styles.rule} />
}
return (
<Text key={index} style={[styles.paragraph, proseScale]}>
<MarkdownText key={index} style={[styles.paragraph, proseScale]}>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line, onOpenFile)}
</Fragment>
))}
</Text>
</MarkdownText>
)
})}
</View>
@@ -152,4 +152,42 @@ describe('MobileNativeChatMessage', () => {
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('makes outgoing bubble text selectable for long-press copy', () => {
const tree = render(userMessage([{ type: 'text', text: 'copy me back' }]))
const bubble = tree.root
.findAllByType('Text' as never)
.find((node) => String(node.children.join('')) === 'copy me back')
expect(bubble).toBeDefined()
expect(bubble!.props.selectable).toBe(true)
expect(isInsidePressable(bubble!)).toBe(false)
})
it('makes image-ref fallback text selectable', () => {
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
const fallback = tree.root
.findAllByType('Text' as never)
.find((node) => String(node.children.join('')).includes('/tmp/host.png'))
expect(fallback).toBeDefined()
expect(fallback!.props.selectable).toBe(true)
})
it('renders incoming prose as markdown instead of a non-selectable bubble', () => {
const tree = render(toolMessage([{ type: 'text', text: 'agent reply' }]))
expect(tree.root.findAllByType('MobileMarkdown' as never)).toHaveLength(1)
expect(
tree.root.findAllByType('Text' as never).some((node) => node.props.selectable === false)
).toBe(false)
})
})
function isInsidePressable(node: ReactTestInstance): boolean {
let current: ReactTestInstance | null = node.parent
while (current) {
if (current.type === ('Pressable' as never)) {
return true
}
current = current.parent
}
return false
}
@@ -150,7 +150,9 @@ function Prose({
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>{block.text}</Text>
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
{block.text}
</Text>
)
}
return (
@@ -172,7 +174,7 @@ function Prose({
)
}
return (
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
<Text selectable style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</Text>
)