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') +}