From 81c9f1b789be2684bfda064c801d7002044d63c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 18 Aug 2026 04:48:13 +0200 Subject: [PATCH 1/3] feat(mobile): add range text selection to message details Add a Select text action under Copy message in the message details sheet. It opens a child page-sheet that reuses SelectableText to show the copyable body for platform range selection. Project canSelectText from getMessageDetailsContent: true only when the message has copyable text and no part is in flight. A user text part never streams; an assistant text part is in flight only when time exists without end. Reasoning and tool parts reuse isPartStreaming. --- .../agents/message-details-content.ts | 22 ++ .../message-details-sheet.mounted.test.tsx | 210 ++++++++++++++++++ .../agents/message-details-sheet.test.ts | 108 +++++++++ .../agents/message-details-sheet.tsx | 164 ++++++++------ .../agents/message-text-select-sheet.tsx | 45 ++++ 5 files changed, 485 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx create mode 100644 apps/mobile/src/components/agents/message-text-select-sheet.tsx diff --git a/apps/mobile/src/components/agents/message-details-content.ts b/apps/mobile/src/components/agents/message-details-content.ts index be8c8c0c7f..5d8615c5c7 100644 --- a/apps/mobile/src/components/agents/message-details-content.ts +++ b/apps/mobile/src/components/agents/message-details-content.ts @@ -5,6 +5,7 @@ import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { collectCopyableText } from './collect-copyable-text'; import { formatCost } from './context-usage-display'; import { resolveMessageDisplayModel } from './message-model-label'; +import { isPartStreaming } from './part-types'; import { friendlyModelName } from './session-model-display'; type MessageDetailsTokenRow = { @@ -19,6 +20,7 @@ type MessageDetailsContent = { costLabel: string | null; tokenRows: MessageDetailsTokenRow[] | null; copyableText: string | null; + canSelectText: boolean; }; const SENT_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { @@ -39,6 +41,9 @@ export function getMessageDetailsContent( const sentTimeLabel = formatMessageSentTime(message.info.time.created); const copyable = collectCopyableText(message); const copyableText = copyable.length > 0 ? copyable : null; + const canSelectText = + copyableText !== null && + !message.parts.some(part => isPartInFlightForSelect(part, message.info.role)); if (message.info.role !== 'assistant') { return { @@ -48,6 +53,7 @@ export function getMessageDetailsContent( costLabel: null, tokenRows: null, copyableText, + canSelectText, }; } @@ -75,9 +81,25 @@ export function getMessageDetailsContent( ] : null, copyableText, + canSelectText, }; } +/** + * A part blocks range selection while it is in flight. A user text part never + * streams, so only an assistant text part with a `time` that lacks `end` is + * in flight; reasoning and tool parts reuse the shared streaming check. + */ +function isPartInFlightForSelect( + part: StoredMessage['parts'][number], + role: StoredMessage['info']['role'] +): boolean { + if (part.type === 'text') { + return role === 'assistant' && part.time !== undefined && part.time.end === undefined; + } + return isPartStreaming(part); +} + type AssistantUsage = { cost: number; input: number; diff --git a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx new file mode 100644 index 0000000000..5c9f0e70fb --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx @@ -0,0 +1,210 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { + type AssistantMessage, + type Part, + type StoredMessage, + type UserMessage, +} from '@kilocode/cloud-agent-sdk'; +import { createElement, type ReactElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { MessageDetailsSheet } from './message-details-sheet'; + +vi.mock('react-native', () => ({ + Modal: 'Modal', + ScrollView: 'ScrollView', + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock('@/components/sheet-header', () => ({ + SheetHeader: 'SheetHeader', +})); +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { + Text: 'Text', + TextClassContext: React.createContext(undefined), + }; +}); +vi.mock('@/components/ui/selectable-text', () => ({ + SelectableText: 'SelectableText', +})); +vi.mock('./message-details-copy', () => ({ + handleMessageDetailsCopy: vi.fn(), +})); + +function userInfo(overrides: Partial = {}): UserMessage { + return { + id: 'u-1', + sessionID: 'ses-1', + role: 'user', + time: { created: 1_700_000_000_000 }, + agent: 'test', + model: { providerID: 'kilo', modelID: 'claude-sonnet-4' }, + ...overrides, + }; +} + +function assistantInfo(overrides: Partial = {}): AssistantMessage { + return { + id: 'msg-1', + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1_700_000_000_000 }, + parentID: 'msg-0', + modelID: 'claude-sonnet-4', + providerID: 'kilo', + mode: 'code', + agent: 'test', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + ...overrides, + }; +} + +function textPart(text: string, id = 'p-text'): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'text', + text, + }; +} + +function textPartWithTime( + text: string, + time: { start: number; end?: number }, + id = 'p-text-time' +): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'text', + text, + time, + }; +} + +function storedMessage(info: AssistantMessage | UserMessage, parts: Part[] = []): StoredMessage { + return { info, parts }; +} + +function sheetElement(message: StoredMessage | null): ReactElement { + return createElement(MessageDetailsSheet, { + visible: true, + message, + modelOptions: [], + onClose: vi.fn<() => void>(), + }); +} + +function findByTestID( + root: TestRenderer.ReactTestInstance, + testID: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => node.props.testID === testID); +} + +function press(instance: TestRenderer.ReactTestInstance | undefined): void { + if (!instance) { + throw new Error('target not found'); + } + const onPress = instance.props.onPress as (() => void) | undefined; + if (typeof onPress !== 'function') { + throw new TypeError('target has no onPress'); + } + onPress(); +} + +async function mountSheet(message: StoredMessage | null): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(sheetElement(message)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +async function unmount(renderer: TestRenderer.ReactTestRenderer): Promise { + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); +} + +describe('MessageDetailsSheet mounted', () => { + it('renders Copy message and Select text for a finished copyable user message', async () => { + const renderer = await mountSheet(storedMessage(userInfo(), [textPart('hello world')])); + + expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(1); + expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(1); + + await unmount(renderer); + }); + + it('opens the child sheet with the copyable body when Select text is pressed', async () => { + const renderer = await mountSheet(storedMessage(userInfo(), [textPart('selectable body')])); + + const modals = () => + renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'Modal' + ); + const visibleModals = () => modals().filter(node => node.props.visible === true); + + // Before press: only the details sheet Modal is visible; the child Modal is hidden. + expect(modals()).toHaveLength(2); + expect(visibleModals()).toHaveLength(1); + + await act(async () => { + await Promise.resolve(); + press(findByTestID(renderer.root, 'message-details-select-text')[0]); + }); + + // After press: the child Modal flips to visible and shows the copyable body. + expect(visibleModals()).toHaveLength(2); + + const selectable = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'SelectableText' + ); + expect(selectable).toHaveLength(1); + expect(selectable[0]?.props.children).toBe('selectable body'); + + await unmount(renderer); + }); + + it('hides Select text for a streaming assistant text part but keeps Copy message', async () => { + const renderer = await mountSheet( + storedMessage(assistantInfo(), [textPartWithTime('streaming body', { start: 1 })]) + ); + + expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(1); + expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(0); + + await unmount(renderer); + }); + + it('renders neither button when there is no copyable text', async () => { + const renderer = await mountSheet(storedMessage(userInfo(), [])); + + expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(0); + expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(0); + + await unmount(renderer); + }); +}); diff --git a/apps/mobile/src/components/agents/message-details-sheet.test.ts b/apps/mobile/src/components/agents/message-details-sheet.test.ts index d1913572e0..1de5dfe041 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.test.ts +++ b/apps/mobile/src/components/agents/message-details-sheet.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- cohesive unit suite: projection, empty, canSelectText, and copy wiring share one harness */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -261,6 +262,113 @@ describe('getMessageDetailsContent — empty', () => { }); }); +function textPartWithTime( + text: string, + time: { start: number; end?: number }, + id = 'p-text-time' +): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'text', + text, + time, + }; +} + +function reasoningPart( + text: string, + time: { start: number; end?: number }, + id = 'p-reasoning' +): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'reasoning', + text, + time, + }; +} + +function runningToolPart(id = 'p-tool'): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'tool', + callID: 'call-1', + tool: 'bash', + state: { status: 'running', input: { command: 'echo hi' }, time: { start: 1 } }, + }; +} + +describe('getMessageDetailsContent — canSelectText', () => { + it('allows selection for a finished user text part with no time', () => { + const message = storedMessage(userInfo(), [textPart('user body')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('user body'); + expect(content.canSelectText).toBe(true); + }); + + it('allows selection for a reconciled user text part with start and no end', () => { + const message = storedMessage(userInfo(), [textPartWithTime('user body', { start: 1 })]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('user body'); + expect(content.canSelectText).toBe(true); + }); + + it('allows selection for a finished assistant text part with time.end', () => { + const message = storedMessage(assistantInfo(), [ + textPartWithTime('assistant body', { start: 1, end: 2 }), + ]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('assistant body'); + expect(content.canSelectText).toBe(true); + }); + + it('allows selection for a finished assistant text part with no time', () => { + const message = storedMessage(assistantInfo(), [textPart('assistant body')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('assistant body'); + expect(content.canSelectText).toBe(true); + }); + + it('hides selection for a streaming assistant text part but keeps copyable text', () => { + const message = storedMessage(assistantInfo(), [ + textPartWithTime('streaming body', { start: 1 }), + ]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('streaming body'); + expect(content.canSelectText).toBe(false); + }); + + it('hides selection while an assistant tool part is running', () => { + const message = storedMessage(assistantInfo(), [runningToolPart()]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).not.toBeNull(); + expect(content.canSelectText).toBe(false); + }); + + it('hides selection while an assistant reasoning part is in flight', () => { + const message = storedMessage(assistantInfo(), [ + textPart('finished text'), + reasoningPart('thinking...', { start: 1 }), + ]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).not.toBeNull(); + expect(content.canSelectText).toBe(false); + }); + + it('hides both actions for a user message with no parts', () => { + const message = storedMessage(userInfo(), []); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBeNull(); + expect(content.canSelectText).toBe(false); + }); +}); + describe('MessageDetailsSheet copy button wiring (retryable unhappy)', () => { beforeEach(() => { performCopyMock.mockReset().mockResolvedValue(undefined); diff --git a/apps/mobile/src/components/agents/message-details-sheet.tsx b/apps/mobile/src/components/agents/message-details-sheet.tsx index bd6eec03ab..e7bbf45297 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.tsx @@ -1,5 +1,5 @@ import { type StoredMessage } from '@kilocode/cloud-agent-sdk'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Modal, Pressable, ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -10,6 +10,7 @@ import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { formatExactTokens } from './context-usage-display'; import { handleMessageDetailsCopy } from './message-details-copy'; import { getMessageDetailsContent } from './message-details-content'; +import { MessageTextSelectSheet } from './message-text-select-sheet'; type MessageDetailsSheetProps = { visible: boolean; @@ -25,84 +26,119 @@ export function MessageDetailsSheet({ onClose, }: Readonly) { const insets = useSafeAreaInsets(); + const [selectVisible, setSelectVisible] = useState(false); const content = useMemo( () => (message ? getMessageDetailsContent(message, modelOptions) : null), [message, modelOptions] ); + useEffect(() => { + if (!visible) { + setSelectVisible(false); + } + }, [visible]); + const handleCopy = () => { handleMessageDetailsCopy(content?.copyableText); }; return ( - - - - - {content ? ( - - {content.copyableText ? ( - - - Copy message - - - ) : null} - - - - {content.roleLabel} - - - {content.sentTimeLabel ? ( - - - {content.sentTimeLabel} - - - ) : null} + <> + + + - {content.modelLabel ? ( - - - {content.modelLabel} - - + {content ? ( + + {content.copyableText ? ( + + + + Copy message + + + + {content.canSelectText ? ( + { + setSelectVisible(true); + }} + accessibilityRole="button" + accessibilityLabel="Select text" + className="rounded-md border border-border px-4 py-3 active:opacity-70" + testID="message-details-select-text" + > + + Select text + + + ) : null} + ) : null} - - - {content.costLabel && content.tokenRows ? ( - - Cost & tokens - - - {content.costLabel} - + + + + {content.roleLabel} - - {content.tokenRows.map(row => ( - - ))} - + + {content.sentTimeLabel ? ( + + + {content.sentTimeLabel} + + + ) : null} + + {content.modelLabel ? ( + + + {content.modelLabel} + + + ) : null} - ) : null} - - ) : null} - - - + {content.costLabel && content.tokenRows ? ( + + Cost & tokens + + + {content.costLabel} + + + + {content.tokenRows.map(row => ( + + ))} + + + ) : null} + + ) : null} + + + + + + { + setSelectVisible(false); + }} + /> + ); } diff --git a/apps/mobile/src/components/agents/message-text-select-sheet.tsx b/apps/mobile/src/components/agents/message-text-select-sheet.tsx new file mode 100644 index 0000000000..8144518424 --- /dev/null +++ b/apps/mobile/src/components/agents/message-text-select-sheet.tsx @@ -0,0 +1,45 @@ +import { Modal, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SheetHeader } from '@/components/sheet-header'; +import { SelectableText } from '@/components/ui/selectable-text'; + +type MessageTextSelectSheetProps = { + visible: boolean; + text: string; + onClose: () => void; +}; + +/** + * Child page-sheet that shows the copyable message body in a read-only + * selectable field. The parent only sets `visible` when `text.length > 0`, so + * an empty string never renders `SelectableText`. + */ +export function MessageTextSelectSheet({ + visible, + text, + onClose, +}: Readonly) { + const insets = useSafeAreaInsets(); + + return ( + + + + + {text.length > 0 ? ( + + {text} + + ) : null} + + + + + ); +} From ec6c92307d91ed0f88fc04436823149f70d69c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 18 Aug 2026 06:02:45 +0200 Subject: [PATCH 2/3] fix(mobile): swap details sheet content instead of nesting a second Modal A second pageSheet Modal rendered as a sibling of the already-presented details pageSheet never presents on iOS. Swap the single Modal's content: when Select text is tapped, the details Modal renders the select view (SheetHeader 'Select text' plus the selectable body) in place of the details content. Select text Done returns to details; details Done closes the sheet. --- .../message-details-sheet.mounted.test.tsx | 19 +++++---- .../agents/message-details-sheet.tsx | 33 ++++++++------- .../agents/message-text-select-sheet.tsx | 40 +++++++------------ 3 files changed, 42 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx index 5c9f0e70fb..ad5aaadb98 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx @@ -158,26 +158,31 @@ describe('MessageDetailsSheet mounted', () => { await unmount(renderer); }); - it('opens the child sheet with the copyable body when Select text is pressed', async () => { + it('swaps the details Modal content to the select view when Select text is pressed', async () => { const renderer = await mountSheet(storedMessage(userInfo(), [textPart('selectable body')])); const modals = () => renderer.root.findAll( node => typeof node.type === 'string' && (node.type as string) === 'Modal' ); - const visibleModals = () => modals().filter(node => node.props.visible === true); + const sheetHeaderTitles = () => + renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'SheetHeader') + .map(node => node.props.title as string | undefined); - // Before press: only the details sheet Modal is visible; the child Modal is hidden. - expect(modals()).toHaveLength(2); - expect(visibleModals()).toHaveLength(1); + // Before press: a single Modal shows the details content. + expect(modals()).toHaveLength(1); + expect(sheetHeaderTitles()).toContain('Message details'); await act(async () => { await Promise.resolve(); press(findByTestID(renderer.root, 'message-details-select-text')[0]); }); - // After press: the child Modal flips to visible and shows the copyable body. - expect(visibleModals()).toHaveLength(2); + // After press: the same single Modal swaps to the Select text view. + expect(modals()).toHaveLength(1); + expect(sheetHeaderTitles()).toContain('Select text'); + expect(sheetHeaderTitles()).not.toContain('Message details'); const selectable = renderer.root.findAll( node => typeof node.type === 'string' && (node.type as string) === 'SelectableText' diff --git a/apps/mobile/src/components/agents/message-details-sheet.tsx b/apps/mobile/src/components/agents/message-details-sheet.tsx index e7bbf45297..a66bd66c3c 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.tsx @@ -43,13 +43,20 @@ export function MessageDetailsSheet({ }; return ( - <> - + + {selectVisible ? ( + { + setSelectVisible(false); + }} + /> + ) : ( @@ -129,16 +136,8 @@ export function MessageDetailsSheet({ - - - { - setSelectVisible(false); - }} - /> - + )} + ); } diff --git a/apps/mobile/src/components/agents/message-text-select-sheet.tsx b/apps/mobile/src/components/agents/message-text-select-sheet.tsx index 8144518424..bd4e55c662 100644 --- a/apps/mobile/src/components/agents/message-text-select-sheet.tsx +++ b/apps/mobile/src/components/agents/message-text-select-sheet.tsx @@ -1,45 +1,33 @@ -import { Modal, ScrollView, View } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SheetHeader } from '@/components/sheet-header'; import { SelectableText } from '@/components/ui/selectable-text'; type MessageTextSelectSheetProps = { - visible: boolean; text: string; onClose: () => void; }; /** - * Child page-sheet that shows the copyable message body in a read-only - * selectable field. The parent only sets `visible` when `text.length > 0`, so - * an empty string never renders `SelectableText`. + * Content-only view for the details sheet's Select text mode. The parent + * renders this inside the single details Modal when `selectVisible` is true. + * An empty string never renders `SelectableText`. */ -export function MessageTextSelectSheet({ - visible, - text, - onClose, -}: Readonly) { +export function MessageTextSelectSheet({ text, onClose }: Readonly) { const insets = useSafeAreaInsets(); return ( - - - + + - {text.length > 0 ? ( - - {text} - - ) : null} + {text.length > 0 ? ( + + {text} + + ) : null} - - - + + ); } From 33849fe042b2367590351b3da2770862764aaba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 18 Aug 2026 11:40:02 +0200 Subject: [PATCH 3/3] fix(mobile): return to details view on Android back in select text The single details Modal closed the whole sheet on Android hardware back even while the Select text view was shown, inconsistent with the Done button. Route onRequestClose back to the details view when selectVisible is true. --- .../message-details-sheet.mounted.test.tsx | 36 +++++++++++++++++++ .../agents/message-details-sheet.tsx | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx index ad5aaadb98..b4a8685a5f 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx @@ -193,6 +193,42 @@ describe('MessageDetailsSheet mounted', () => { await unmount(renderer); }); + it('returns to the details view when Android back is pressed in the Select text view', async () => { + const renderer = await mountSheet(storedMessage(userInfo(), [textPart('selectable body')])); + + const modal = () => { + const found = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'Modal' + ); + if (!found[0]) { + throw new Error('Modal not found'); + } + return found[0]; + }; + + await act(async () => { + await Promise.resolve(); + press(findByTestID(renderer.root, 'message-details-select-text')[0]); + }); + + const onRequestClose = modal().props.onRequestClose as (() => void) | undefined; + expect(typeof onRequestClose).toBe('function'); + + await act(async () => { + await Promise.resolve(); + onRequestClose?.(); + }); + + const sheetHeaderTitles = () => + renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'SheetHeader') + .map(node => node.props.title as string | undefined); + expect(sheetHeaderTitles()).toContain('Message details'); + expect(sheetHeaderTitles()).not.toContain('Select text'); + + await unmount(renderer); + }); + it('hides Select text for a streaming assistant text part but keeps Copy message', async () => { const renderer = await mountSheet( storedMessage(assistantInfo(), [textPartWithTime('streaming body', { start: 1 })]) diff --git a/apps/mobile/src/components/agents/message-details-sheet.tsx b/apps/mobile/src/components/agents/message-details-sheet.tsx index a66bd66c3c..7f4a1f1c4d 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.tsx @@ -47,7 +47,13 @@ export function MessageDetailsSheet({ visible={visible} animationType="slide" presentationStyle="pageSheet" - onRequestClose={onClose} + onRequestClose={ + selectVisible + ? () => { + setSelectVisible(false); + } + : onClose + } > {selectVisible ? (