From 2e4069b32f6559f5daba12aa2fdc8d09a7dee456 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:38:21 -0700 Subject: [PATCH] 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. --- .../MobileNativeChatLiveLine.android.test.ts | 46 +++++++++++++++++++ .../src/session/MobileNativeChatLiveLine.tsx | 13 ++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts index 6100fd92218..daecbe6ab62 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -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']) + }) }) diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index 439b4d4925e..30ead56325c 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -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(null) + const liveMessage = reasoning?.message ?? null + const openActions = useCallback(() => setActionsFor(liveMessage), [liveMessage]) return ( {/* One element for every state of the line, so TalkBack hears each new label; the body @@ -70,10 +73,10 @@ export function MobileNativeChatLiveLine({ /> ) : null} - {reasoning && actionsOpen ? ( + {actionsFor ? ( setActionsOpen(false)} + message={actionsFor} + onClose={() => setActionsFor(null)} /> ) : null}