diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 962538a8842..345a8f4786c 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -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) + }) +}) diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index cc88c01e564..75dfe6c19fd 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -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): React.JSX.Element { + return +} + 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 ( - onOpenFile(segment.path)} > {segment.value} - + ) } return {segment.value} @@ -105,22 +110,34 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/) if (image) { parts.push( - openMarkdownHref(image[2]!, onOpenFile)}> + openMarkdownHref(image[2]!, onOpenFile)} + > {image[1] || 'image'} - + ) } else if (link) { parts.push( - openMarkdownHref(link[2]!, onOpenFile)}> + openMarkdownHref(link[2]!, onOpenFile)} + > {link[1]} - + ) } else if (/^https?:\/\//i.test(token)) { const { url, trailing } = trimAutolinkTrailingPunctuation(token) parts.push( - openMarkdownHref(url, onOpenFile)}> + openMarkdownHref(url, onOpenFile)} + > {url} - + ) if (trailing) { parts.push({trailing}) @@ -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( - onOpenFile(normalizeFilePath(code.trim()))} > {code} - + ) } else { parts.push( - + {code} - + ) } } else if (token.startsWith('~~')) { parts.push( - + {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} - + ) } else if (token.startsWith('**') || token.startsWith('__')) { parts.push( - + {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} - + ) } else { parts.push( - + {renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)} - + ) } } @@ -181,7 +198,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile const proseScale = scaled(13) const listScale = scaled(14) if (!text) { - return fallback ? {fallback} : null + return fallback ? {fallback} : null } const mermaidSourceOccurrences = new Map() @@ -190,18 +207,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile {blocks.map((block, index) => { if (block.type === 'heading') { return ( - {renderInline(block.text, onOpenFile)} - + ) } if (block.type === 'quote') { return ( - {renderInline(block.text, onOpenFile)} + + {renderInline(block.text, onOpenFile)} + ) } @@ -222,8 +241,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile } return ( - {block.language ? {block.language} : null} - {block.text} + {block.language ? ( + {block.language} + ) : null} + {block.text} ) } @@ -234,10 +255,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile style={styles.imageFrame} onPress={() => openMarkdownHref(block.url, onOpenFile)} > - {block.alt || 'Open image'} - + {block.alt || 'Open image'} + {block.url} - + ) } @@ -251,26 +272,26 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile {visibleHeaders.map((header, cellIndex) => ( - + {renderInline(header, onOpenFile)} - + ))} {visibleRows.map((row, rowIndex) => ( {visibleHeaders.map((_, cellIndex) => ( - + {renderInline(row[cellIndex] ?? '', onOpenFile)} - + ))} ))} {hiddenRows > 0 || hiddenColumns > 0 ? ( - + {hiddenRows > 0 ? `${hiddenRows} more rows` : ''} {hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''} {hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''} - + ) : null} @@ -281,7 +302,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile {block.items.map((item, itemIndex) => ( - + {item.checked == null ? block.ordered ? `${itemIndex + 1}.` @@ -289,10 +310,10 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile : item.checked ? '[x]' : '[ ]'} - - + + {renderInline(item.text, onOpenFile)} - + ))} @@ -302,14 +323,14 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} {renderInline(line, onOpenFile)} ))} - + ) })} diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 10b96cc3d20..27a608ee8e3 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -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 +} diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 0a676061e5b..7263034c92c 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -150,7 +150,9 @@ function Prose({ // markdown renderer's light-on-dark palette. if (invert) { return ( - {block.text} + + {block.text} + ) } return ( @@ -172,7 +174,7 @@ function Prose({ ) } return ( - + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} )