fix(mobile): keep the live text's actions sheet on the block it was opened for

On Android the sheet opened by a long press on the live reasoning was a flag gated on a live
block: it vanished when the block ended, mid Select text, and the stale flag reopened it
unprompted on the next block. The sheet now holds the message it was opened for.
This commit is contained in:
Brennan Benson
2026-10-05 21:38:21 -07:00
parent 2911a2a995
commit 2e4069b32f
2 changed files with 54 additions and 5 deletions
@@ -74,4 +74,50 @@ describe('MobileNativeChatLiveLine on Android', () => {
act(() => sheet!.props.onClose())
expect(byType('MessageActionsSheet')).toHaveLength(0)
})
it('keeps the sheet through the block ending, and never opens one for the next by itself', () => {
const line = (reasoning: NativeChatLiveReasoning | null) =>
createElement(MobileNativeChatLiveLine, {
line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true },
onToggleReasoning: vi.fn(),
fontScale: 1
})
act(() => {
renderer = create(line(block))
})
act(() => byType('ReasoningBody')[0]!.props.onLongPress())
// The block ends while the reader copies or selects it.
act(() => renderer!.update(line(null)))
expect(byType('MessageActionsSheet').map((sheet) => sheet.props.message)).toEqual([
block.message
])
act(() => byType('MessageActionsSheet')[0]!.props.onClose())
const next: NativeChatLiveReasoning = {
message: { ...block.message, id: 'r-2' },
markdown: 'Next thought'
}
act(() => renderer!.update(line(next)))
expect(byType('MessageActionsSheet')).toHaveLength(0)
})
it('opens no sheet for the next block when the first ends with the sheet open', () => {
const line = (reasoning: NativeChatLiveReasoning | null) =>
createElement(MobileNativeChatLiveLine, {
line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true },
onToggleReasoning: vi.fn(),
fontScale: 1
})
act(() => {
renderer = create(line(block))
})
act(() => byType('ReasoningBody')[0]!.props.onLongPress())
act(() => renderer!.update(line(null)))
const next: NativeChatLiveReasoning = {
message: { ...block.message, id: 'r-2' },
markdown: 'Next thought'
}
act(() => renderer!.update(line(next)))
// Still the first block's sheet, the one the reader opened; none for r-2.
expect(byType('MessageActionsSheet').map((sheet) => sheet.props.message.id)).toEqual(['r-1'])
})
})
@@ -3,6 +3,7 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati
import { ChevronRight } from 'lucide-react-native'
import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row'
import { formatNativeChatActiveTurnLabel } from '../../../src/shared/native-chat-turn-status'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
@@ -28,8 +29,10 @@ export function MobileNativeChatLiveLine({
const open = reasoning !== null && reasoningExpanded
const label = formatNativeChatActiveTurnLabel(line)
// Android has no inline selection; the finished row's long-press sheet copies the live text too.
const [actionsOpen, setActionsOpen] = useState(false)
const openActions = useCallback(() => setActionsOpen(true), [])
// It holds the block it opened for, so it outlives that block ending and never reopens by itself.
const [actionsFor, setActionsFor] = useState<NativeChatMessage | null>(null)
const liveMessage = reasoning?.message ?? null
const openActions = useCallback(() => setActionsFor(liveMessage), [liveMessage])
return (
<View>
{/* One element for every state of the line, so TalkBack hears each new label; the body
@@ -70,10 +73,10 @@ export function MobileNativeChatLiveLine({
/>
</View>
) : null}
{reasoning && actionsOpen ? (
{actionsFor ? (
<MobileNativeChatMessageActionsSheet
message={reasoning.message}
onClose={() => setActionsOpen(false)}
message={actionsFor}
onClose={() => setActionsFor(null)}
/>
) : null}
</View>