fix(mobile): stop Android from selecting words while the chat transcript scrolls (#22871)

* fix(mobile): stop Android from selecting words while the chat transcript scrolls

On Android every paragraph, heading, quote, code block and table cell in
the native chat transcript was a selectable TextView. Android starts a
word selection, with the magnifier, on a double tap or a long press, and
two flicks in the same spot while scrolling a FlatList register as a
double tap, so scrolling the chat kept selecting words. iOS is unaffected:
its UITextView path arbitrates scroll against selection itself.

Android now renders transcript text without inline selection: one gate in
MarkdownText covers every selectable span, and the user bubble follows
it. A long press on a message opens a sheet with "Copy message" and
"Select text", the latter a screen whose only content is one selectable
Text, so a selection can only start where the user asked for it. The
message row owns that sheet and mounts it only while open. iOS and web
keep their inline selection and get no long-press handler.

Verified on a Pixel 10 Pro Fold (Android 17): an adb double tap on the
transcript selects nothing, the same double tap inside "Select text"
selects a word, tool rows inside the bubble still expand on tap, and the
long press opens the sheet.

* fix(mobile): scope the Android selection gate to the transcript and route span long presses

Review follow-ups on #22871:

- The gate now applies only where `rangeSelectable` is passed (the chat
  transcript). Task comments and file previews keep their selectable text
  on Android as before.
- On the Android transcript, spans that take taps (links, file paths)
  also take the row's long press, so a link under the finger no longer
  swallows the copy/select sheet.
- Copied text keeps its whitespace; only whitespace-only blocks are dropped.
- The Android markdown test compares `String(node.type)` instead of a
  type assertion, which the changed-code quality gate rejects.

* fix(mobile): route long presses on Markdown images to the row on Android

An image block is a Pressable of its own, so on the Android transcript it
now carries the row's onLongPress like tappable spans do; a long press on
an image opens the copy/select sheet instead of being swallowed. Test
extended with an image block.

* test(mobile): pin the Android long press from a chat row to its actions sheet

The existing row suite runs as iOS, where the bubble has no long press.
This one runs as Android: the bubble's long press mounts the actions
sheet with the message, the markdown receives the same handler, closing
unmounts the sheet, and the user bubble carries no inline selection.

* fix(mobile): preserve Android message selection while replies stream

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Daiki Hirayama
2026-10-05 01:41:19 -07:00
committed by GitHub
co-authored by Neil
parent 1b957b70a8
commit c2c7649849
14 changed files with 629 additions and 12 deletions
@@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve())
vi.mock('react-native', () => ({
Linking: { openURL: (url: string) => openURL(url) },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
+39 -5
View File
@@ -1,5 +1,6 @@
import { openExternalLink } from '../platform/external-link'
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
import { MobileSelectableText } from './MobileSelectableText'
import {
Fragment,
@@ -33,6 +34,8 @@ type Props = {
fallback?: string
/** Enables iOS range selection for native-chat transcript prose. */
rangeSelectable?: boolean
/** Forward long presses from interactive Android transcript spans to the message. */
onLongPress?: () => void
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
@@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
const MarkdownTextContext = createContext<ComponentType<TextProps>>(NativeText)
type MarkdownTextSetup = {
TextComponent: ComponentType<TextProps>
/** Disable native selection only within the Android transcript. */
androidTranscript: boolean
onLongPress?: () => void
}
const MarkdownTextContext = createContext<MarkdownTextSetup>({
TextComponent: NativeText,
androidTranscript: false
})
function MarkdownText(props: TextProps): React.JSX.Element {
const TextComponent = useContext(MarkdownTextContext)
return createElement(TextComponent, props)
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
if (!androidTranscript) {
return createElement(TextComponent, props)
}
// Override selection without changing the nested spans' inherited behavior.
return createElement(TextComponent, {
...props,
...(props.selectable === true ? { selectable: false } : {}),
...(onLongPress && props.onPress ? { onLongPress } : {})
})
}
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
@@ -215,6 +235,9 @@ function MobileMarkdownContent({
textScale = 1,
onOpenFile
}: Props) {
// Interactive children own their touches and must forward the row action.
const setup = useContext(MarkdownTextContext)
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
const text = content?.trim() ?? ''
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
@@ -288,6 +311,7 @@ function MobileMarkdownContent({
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
onLongPress={rowLongPress}
>
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
@@ -380,9 +404,19 @@ function MobileMarkdownContent({
}
function MobileMarkdownInner(props: Props): React.JSX.Element | null {
const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText
const { rangeSelectable = false, onLongPress } = props
// Other Markdown surfaces retain their existing selection behavior.
const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION
const setup = useMemo<MarkdownTextSetup>(
() => ({
TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText,
androidTranscript,
...(onLongPress ? { onLongPress } : {})
}),
[rangeSelectable, androidTranscript, onLongPress]
)
return (
<MarkdownTextContext.Provider value={TextComponent}>
<MarkdownTextContext.Provider value={setup}>
<MobileMarkdownContent {...props} />
</MarkdownTextContext.Provider>
)
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }))
const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } =
await import('./inline-text-selection')
describe('inline text selection policy', () => {
it('keeps inline selection everywhere but Android', () => {
expect(inlineTextSelectionAllowed('ios')).toBe(true)
expect(inlineTextSelectionAllowed('web')).toBe(true)
expect(inlineTextSelectionAllowed('android')).toBe(false)
})
it('resolves the running platform once', () => {
expect(INLINE_TEXT_SELECTION).toBe(false)
})
})
@@ -0,0 +1,8 @@
import { Platform } from 'react-native'
// Android double-tap selection can mistake successive scroll flicks for word selection.
export function inlineTextSelectionAllowed(os: string): boolean {
return os !== 'android'
}
export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS)
@@ -0,0 +1,88 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there;
// this one proves the transcript carries no selectable span on Android, where a selectable
// TextView selects words while the transcript scrolls — and that other surfaces are untouched.
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Platform: { OS: 'android' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
const CONTENT = [
'# Heading',
'A paragraph with **bold**, `code` and https://example.com/link.',
'> quoted',
'- item one',
'| a | b |',
'| - | - |',
'| 1 | 2 |',
'```',
'fenced()',
'```'
].join('\n')
describe('MobileMarkdown on Android', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(props: Parameters<typeof MobileMarkdown>[0]): ReactTestInstance[] {
act(() => {
renderer = create(createElement(MobileMarkdown, props))
})
return renderer!.root.findAll((node) => String(node.type) === 'Text')
}
const tappable = (nodes: ReactTestInstance[]) =>
nodes.filter((node) => typeof node.props.onPress === 'function')
it('renders the transcript with no selectable span, leaving untouched spans alone', () => {
const all = render({ content: CONTENT, rangeSelectable: true })
expect(all.length).toBeGreaterThan(5)
expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0)
// Inline spans (bold, code, links) never asked for selection; writing `false` onto them
// would map to `userSelect: none` on the web, so the gate must leave them alone.
const links = tappable(all)
expect(links.length).toBeGreaterThan(0)
for (const node of links) {
expect(node.props.selectable).toBeUndefined()
}
})
it('routes a long press on a tappable span or image to the row, so neither swallows it', () => {
const onLongPress = vi.fn()
const content = `${CONTENT}\n\n![diagram](https://example.com/diagram.png)`
const all = render({ content, rangeSelectable: true, onLongPress })
const links = tappable(all)
expect(links.length).toBeGreaterThan(0)
for (const node of links) {
expect(node.props.onLongPress).toBe(onLongPress)
}
expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0)
const images = renderer!.root.findAll(
(node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function'
)
expect(images.length).toBeGreaterThan(0)
for (const node of images) {
expect(node.props.onLongPress).toBe(onLongPress)
}
})
it('keeps other surfaces (task comments, previews) selectable as before', () => {
const all = render({ content: CONTENT })
expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0)
})
})
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: vi.fn() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
@@ -0,0 +1,121 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all.
// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet.
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'android' },
Pressable: 'Pressable',
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench',
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
const message: NativeChatMessage = {
id: 'a1',
role: 'assistant',
blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }],
timestamp: null,
source: 'transcript'
}
describe('MobileNativeChatMessage on Android', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function byType(type: string): ReactTestInstance[] {
return renderer!.root.findAll((node) => String(node.type) === type)
}
it('opens the actions sheet from a long press on the bubble, and closes it again', () => {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message }))
})
expect(byType('MessageActionsSheet')).toHaveLength(0)
const [bubble] = byType('Pressable')
expect(typeof bubble!.props.onLongPress).toBe('function')
// The markdown gets the same handler, so a link span or image under the finger opens the sheet.
const [markdown] = byType('MobileMarkdown')
expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress)
expect(markdown!.props.rangeSelectable).toBe(true)
act(() => bubble!.props.onLongPress())
const [sheet] = byType('MessageActionsSheet')
expect(sheet!.props.message).toBe(message)
act(() => sheet!.props.onClose())
expect(byType('MessageActionsSheet')).toHaveLength(0)
})
it('disables inline selection for host notices too', () => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessage, {
message: {
...message,
role: 'system',
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
}
})
)
})
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
expect(texts).toHaveLength(1)
expect(texts[0]!.props.selectable).toBe(false)
act(() => byType('Pressable')[0]!.props.onLongPress())
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
it('renders the user bubble without inline selection', () => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } })
)
})
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
expect(texts.length).toBeGreaterThan(0)
for (const node of texts) {
expect(node.props.selectable).toBe(false)
}
})
})
@@ -22,6 +22,7 @@ vi.mock('react-native', async () => {
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text,
View: ({ children, ...props }: { children?: unknown }) =>
@@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+51 -7
View File
@@ -1,6 +1,8 @@
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react'
import { Image, Text as NativeText, Pressable, View } from 'react-native'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
@@ -20,17 +22,23 @@ function Prose({
block,
invert,
fontScale,
onOpenFile
onOpenFile,
onLongPress
}: {
block: NativeChatBlock
invert?: boolean
fontScale: number
onOpenFile?: (relativePath: string) => void
/** Android only: routes a long press on a link span to the row's actions sheet. */
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
@@ -39,7 +47,10 @@ function Prose({
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}
>
{block.text}
</Text>
)
@@ -50,6 +61,7 @@ function Prose({
rangeSelectable
textScale={1.25 * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
)
}
@@ -76,6 +88,25 @@ function Prose({
return null
}
// Keep the existing responder hierarchy on platforms with inline selection.
function Content({
onLongPress,
style,
children
}: {
onLongPress?: () => void
style: ComponentProps<typeof View>['style']
children: ReactNode
}): React.JSX.Element {
return onLongPress ? (
<Pressable onLongPress={onLongPress} style={style}>
{children}
</Pressable>
) : (
<View style={style}>{children}</View>
)
}
function MobileNativeChatMessageImpl({
message,
toolsExpanded = false,
@@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({
!turnExpanded &&
!toolsExpanded
const showToolRun = tools.length > 0 && !settledToolsHidden
// Mount selection UI only for the message being copied.
const [actionsOpen, setActionsOpen] = useState(false)
// Keep the memoized Markdown context stable as the message streams.
const openActions = useCallback(() => setActionsOpen(true), [])
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
const statusRow = turnStatus ? (
<MobileNativeChatTurnStatus
@@ -144,7 +180,8 @@ function MobileNativeChatMessageImpl({
{/* A turn with no user bubble carries its bar above its first row. */}
{turnStatusAbove ? statusRow : null}
<View style={[styles.row, isUser && styles.rowUser]}>
<View
<Content
onLongPress={onLongPress}
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
>
{prose.map((block, index) => (
@@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
))}
{showToolRun ? (
@@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({
onOpenFile={onOpenFile}
/>
) : null}
</View>
</Content>
</View>
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
@@ -0,0 +1,126 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() }))
vi.mock('react-native', () => ({
Alert: { alert },
ActivityIndicator: 'ActivityIndicator',
Modal: 'Modal',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
Text: 'Text',
View: 'View',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 })
}))
vi.mock('lucide-react-native', () => ({
Copy: 'Copy',
TextSelect: 'TextSelect',
X: 'X',
Edit3: 'Edit3',
Trash2: 'Trash2'
}))
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) }))
// Keep the real drawer lifecycle; only native animation completion is driven by the test.
vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' }))
const message: NativeChatMessage = {
id: 'reply',
source: 'transcript',
role: 'assistant',
timestamp: null,
blocks: [{ type: 'text', text: 'First words' }]
}
describe('message selection actions', () => {
let renderer: ReactTestRenderer | null = null
const onClose = vi.fn()
const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type)
const render = (value = message) => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose })
)
})
}
const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress())
const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden())
beforeEach(() => {
vi.clearAllMocks()
writeText.mockResolvedValue(undefined)
})
afterEach(() => act(() => renderer?.unmount()))
it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => {
render()
act(() =>
renderer!.update(
createElement(MobileNativeChatMessageActionsSheet, {
message: {
...message,
blocks: [{ type: 'text', text: 'First words plus streamed text' }]
},
onClose
})
)
)
pressAction(1)
expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false)
expect(nodes('Modal')).toHaveLength(0)
expect(onClose).not.toHaveBeenCalled()
finishClosing()
expect(nodes('MountedDrawer')).toHaveLength(0)
expect(nodes('Modal')).toHaveLength(1)
const selected = nodes('Text').find((node) => node.props.selectable)
expect(selected?.props.children).toBe('First words')
act(() => nodes('Modal')[0]!.props.onRequestClose())
expect(onClose).toHaveBeenCalledOnce()
})
it('closes selection with the accessible close button', () => {
render()
pressAction(1)
finishClosing()
const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close')
expect(close?.props.accessibilityRole).toBe('button')
act(() => close!.props.onPress())
expect(onClose).toHaveBeenCalledOnce()
})
it('copies prose and dismisses only after the drawer closes', () => {
render()
pressAction(0)
expect(writeText).toHaveBeenCalledWith('First words')
expect(onClose).not.toHaveBeenCalled()
finishClosing()
expect(onClose).toHaveBeenCalledOnce()
expect(nodes('Modal')).toHaveLength(0)
})
it('surfaces clipboard rejection', async () => {
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
render()
await act(async () => pressAction(0))
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
})
it('dismisses without selecting when the drawer is cancelled', () => {
render()
act(() => nodes('MountedDrawer')[0]!.props.onClose())
finishClosing()
expect(onClose).toHaveBeenCalledOnce()
expect(nodes('Modal')).toHaveLength(0)
})
it('disables actions when the message has no copyable prose', () => {
render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] })
expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true])
expect(writeText).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,119 @@
import { useRef, useState } from 'react'
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Copy, TextSelect, X } from 'lucide-react-native'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { useClipboardWriter } from '../platform/clipboard'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
type Props = {
/** The long-pressed message. The owner mounts this only while the sheet is open. */
message: NativeChatMessage
onClose: () => void
}
export function MobileNativeChatMessageActionsSheet({
message,
onClose
}: Props): React.JSX.Element {
const clipboard = useClipboardWriter()
const [sheetVisible, setSheetVisible] = useState(true)
const [selecting, setSelecting] = useState(false)
// Wait for the drawer to unmount before presenting another native modal.
const selectRequested = useRef(false)
// Streaming updates must not reset an active native text selection.
const [text] = useState(() => nativeChatMessagePlainText(message))
const closeSheet = () => setSheetVisible(false)
return (
<>
<BottomDrawer
visible={sheetVisible}
onClose={closeSheet}
onAfterClose={() => (selectRequested.current ? setSelecting(true) : onClose())}
dragContentToDismiss
>
<ActionSheetContent
onClose={closeSheet}
actions={[
{
label: 'Copy message',
icon: Copy,
disabled: text.length === 0,
onPress: () => {
void clipboard.writeText(text).catch((error: unknown) => {
Alert.alert(
'Copy failed',
error instanceof Error ? error.message : 'The clipboard rejected the text.'
)
})
}
},
{
label: 'Select text',
icon: TextSelect,
disabled: text.length === 0,
onPress: () => {
selectRequested.current = true
}
}
]}
/>
</BottomDrawer>
{selecting ? <SelectTextScreen text={text} onClose={onClose} /> : null}
</>
)
}
function SelectTextScreen({
text,
onClose
}: {
text: string
onClose: () => void
}): React.JSX.Element {
const insets = useSafeAreaInsets()
return (
<Modal visible animationType="slide" onRequestClose={onClose}>
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<Text style={styles.title}>Select text</Text>
<Pressable
onPress={onClose}
hitSlop={12}
accessibilityLabel="Close"
accessibilityRole="button"
style={styles.close}
>
<X size={20} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.text}>
{text}
</Text>
</ScrollView>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' },
close: { padding: spacing.xs },
body: { padding: spacing.lg },
text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 }
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
describe('nativeChatMessagePlainText', () => {
it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: ' indented first line\n' },
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'text', text: ' \n' },
{ type: 'text', text: 'Second, with `code`.' }
]
expect(nativeChatMessagePlainText({ blocks })).toBe(
' indented first line\n\n\nSecond, with `code`.'
)
})
it('copies the displayed host notice instead of its fallback wire text', () => {
expect(
nativeChatMessagePlainText({
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
})
).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
})
it('is empty for a message with no prose', () => {
expect(
nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] })
).toBe('')
})
})
@@ -0,0 +1,20 @@
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import { isTextBlock } from '../../../src/shared/native-chat-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
/** Copy the displayed prose, excluding tool activity and image attachments. */
export function nativeChatMessagePlainText(message: Pick<NativeChatMessage, 'blocks'>): string {
// Preserve indentation in code and nested lists.
return message.blocks
.filter(isTextBlock)
.map((block) =>
isAgentSessionHostStatusPresentation(block.presentation)
? AGENT_SESSION_HOST_STATUS_COPY[block.presentation]
: block.text
)
.filter((text) => text.trim().length > 0)
.join('\n\n')
}