fix(mobile): keep the long-press actions sheet on reasoning rows for Android

This commit is contained in:
Brennan Benson
2026-10-05 12:25:08 -07:00
parent 9f3e184915
commit c04c4cd3b9
4 changed files with 71 additions and 24 deletions
@@ -0,0 +1,22 @@
import type { ComponentProps, ReactNode } from 'react'
import { Pressable, View } from 'react-native'
/** A message body that opens the actions sheet on long press (Android, which has no inline
* selection). Keep the existing responder hierarchy on platforms with inline selection. */
export function MobileNativeChatLongPressContent({
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>
)
}
@@ -32,6 +32,7 @@ vi.mock('react-native', async () => {
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Brain: 'Brain',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight',
@@ -106,6 +107,36 @@ describe('MobileNativeChatMessage on Android', () => {
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
it('opens the actions sheet from a long press on an expanded reasoning row, not on its headline', () => {
const reasoning: NativeChatMessage = {
...message,
id: 'r1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: 1_000,
state: 'completed',
completedAt: 4_000
}
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: reasoning }))
})
const [toggle] = byType('Pressable')
// A long press on the headline stays a plain toggle tap.
expect(toggle!.props.onLongPress).toBeUndefined()
act(() => toggle!.props.onPress())
const body = byType('Pressable').find((node) => node.props.onLongPress !== undefined)
expect(typeof body?.props.onLongPress).toBe('function')
const [markdown] = byType('MobileMarkdown')
expect(markdown!.props.onLongPress).toBe(body!.props.onLongPress)
act(() => body!.props.onLongPress())
const [sheet] = byType('MessageActionsSheet')
expect(sheet!.props.message).toBe(reasoning)
act(() => sheet!.props.onClose())
expect(byType('MessageActionsSheet')).toHaveLength(0)
})
it('renders the user bubble without inline selection', () => {
act(() => {
renderer = create(
+10 -21
View File
@@ -1,8 +1,9 @@
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react'
import { Image, Text as NativeText, Pressable, View } from 'react-native'
import { memo, useCallback, useState } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
import { MobileNativeChatLongPressContent as Content } from './MobileNativeChatLongPressContent'
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'
@@ -90,25 +91,6 @@ 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,
@@ -193,9 +175,16 @@ function MobileNativeChatMessageImpl({
markdown={markdown}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
</View>
) : null}
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
@@ -8,6 +8,7 @@ import {
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import { MobileNativeChatLongPressContent } from './MobileNativeChatLongPressContent'
import { styles } from './mobile-native-chat-message-styles'
/** A reasoning row, collapsed to its headline; its text mounts only once opened. Desktop parity:
@@ -16,12 +17,15 @@ export function MobileNativeChatReasoningRow({
message,
markdown,
fontScale,
onOpenFile
onOpenFile,
onLongPress
}: {
message: Pick<NativeChatMessage, 'state' | 'completedAt' | 'timestamp'>
markdown: string
fontScale: number
onOpenFile?: (relativePath: string) => void
/** Android only: opens the message's actions sheet, as a long press on any other message does. */
onLongPress?: () => void
}): React.JSX.Element {
const [expanded, setExpanded] = useState(false)
const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message))
@@ -46,14 +50,15 @@ export function MobileNativeChatReasoningRow({
</View>
</Pressable>
{expanded ? (
<View style={styles.reasoning}>
<MobileNativeChatLongPressContent onLongPress={onLongPress} style={styles.reasoning}>
<MobileMarkdown
content={markdown}
rangeSelectable
textScale={1.25 * fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
</View>
</MobileNativeChatLongPressContent>
) : null}
</View>
)