From c2c76498494e6147b63dcda99f77f87aab63156e Mon Sep 17 00:00:00 2001 From: Daiki Hirayama <33761105+chokolademilch7@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:41:19 +0900 Subject: [PATCH] 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 --- .../MobileMarkdown.file-links.test.ts | 1 + mobile/src/components/MobileMarkdown.tsx | 44 +++++- .../components/inline-text-selection.test.ts | 18 +++ .../src/components/inline-text-selection.ts | 8 ++ ...mobile-markdown-android-selection.test.tsx | 88 ++++++++++++ .../mobile-markdown-mermaid-routing.test.ts | 1 + .../mobile-markdown-selectable.test.tsx | 1 + .../MobileNativeChatMessage.android.test.ts | 121 +++++++++++++++++ .../session/MobileNativeChatMessage.test.ts | 4 + .../src/session/MobileNativeChatMessage.tsx | 58 +++++++- ...bileNativeChatMessageActionsSheet.test.tsx | 126 ++++++++++++++++++ .../MobileNativeChatMessageActionsSheet.tsx | 119 +++++++++++++++++ ...ile-native-chat-message-plain-text.test.ts | 32 +++++ .../mobile-native-chat-message-plain-text.ts | 20 +++ 14 files changed, 629 insertions(+), 12 deletions(-) create mode 100644 mobile/src/components/inline-text-selection.test.ts create mode 100644 mobile/src/components/inline-text-selection.ts create mode 100644 mobile/src/components/mobile-markdown-android-selection.test.tsx create mode 100644 mobile/src/session/MobileNativeChatMessage.android.test.ts create mode 100644 mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx create mode 100644 mobile/src/session/MobileNativeChatMessageActionsSheet.tsx create mode 100644 mobile/src/session/mobile-native-chat-message-plain-text.test.ts create mode 100644 mobile/src/session/mobile-native-chat-message-plain-text.ts diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 28a3155203a..c2243cd6cc3 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -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 }, diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 3e3d7a8b906..0022daf8d73 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -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>(NativeText) +type MarkdownTextSetup = { + TextComponent: ComponentType + /** Disable native selection only within the Android transcript. */ + androidTranscript: boolean + onLongPress?: () => void +} +const MarkdownTextContext = createContext({ + 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} > {block.alt || 'Open image'} @@ -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( + () => ({ + TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText, + androidTranscript, + ...(onLongPress ? { onLongPress } : {}) + }), + [rangeSelectable, androidTranscript, onLongPress] + ) return ( - + ) diff --git a/mobile/src/components/inline-text-selection.test.ts b/mobile/src/components/inline-text-selection.test.ts new file mode 100644 index 00000000000..72fcad28afe --- /dev/null +++ b/mobile/src/components/inline-text-selection.test.ts @@ -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) + }) +}) diff --git a/mobile/src/components/inline-text-selection.ts b/mobile/src/components/inline-text-selection.ts new file mode 100644 index 00000000000..9e9165709a1 --- /dev/null +++ b/mobile/src/components/inline-text-selection.ts @@ -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) diff --git a/mobile/src/components/mobile-markdown-android-selection.test.tsx b/mobile/src/components/mobile-markdown-android-selection.test.tsx new file mode 100644 index 00000000000..9567ae796bb --- /dev/null +++ b/mobile/src/components/mobile-markdown-android-selection.test.tsx @@ -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[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) + }) +}) diff --git a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts index c89dfa00853..833064ee3fc 100644 --- a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts +++ b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts @@ -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: (styles: T) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/mobile-markdown-selectable.test.tsx b/mobile/src/components/mobile-markdown-selectable.test.tsx index 2b42fc70715..d581a9ebb5f 100644 --- a/mobile/src/components/mobile-markdown-selectable.test.tsx +++ b/mobile/src/components/mobile-markdown-selectable.test.tsx @@ -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 }, diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts new file mode 100644 index 00000000000..fff2a8d9eba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -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) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index c8193641fef..135469d8917 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -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' diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 40635166281..83f93266d6f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -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 ( - + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} ) @@ -39,7 +47,10 @@ function Prose({ // markdown renderer's light-on-dark palette. if (invert) { return ( - + {block.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['style'] + children: ReactNode +}): React.JSX.Element { + return onLongPress ? ( + + {children} + + ) : ( + {children} + ) +} + 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 ? ( - {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} - + + {actionsOpen ? ( + setActionsOpen(false)} + /> + ) : null} {turnStatusAbove ? null : statusRow} ) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx new file mode 100644 index 00000000000..34d4089af9b --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx @@ -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() + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx new file mode 100644 index 00000000000..4d9cf71c6ba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx @@ -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 ( + <> + (selectRequested.current ? setSelecting(true) : onClose())} + dragContentToDismiss + > + { + 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 + } + } + ]} + /> + + {selecting ? : null} + + ) +} + +function SelectTextScreen({ + text, + onClose +}: { + text: string + onClose: () => void +}): React.JSX.Element { + const insets = useSafeAreaInsets() + return ( + + + + Select text + + + + + + + {text} + + + + + ) +} + +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 } +}) diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.test.ts b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts new file mode 100644 index 00000000000..b115cab8268 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts @@ -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('') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.ts b/mobile/src/session/mobile-native-chat-message-plain-text.ts new file mode 100644 index 00000000000..40ca75dfaf6 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.ts @@ -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): 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') +}