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..b4a8685a5f --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx @@ -0,0 +1,251 @@ +/* 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('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 sheetHeaderTitles = () => + renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'SheetHeader') + .map(node => node.props.title as string | undefined); + + // 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 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' + ); + expect(selectable).toHaveLength(1); + expect(selectable[0]?.props.children).toBe('selectable body'); + + 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 })]) + ); + + 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..7f4a1f1c4d 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,11 +26,18 @@ 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); }; @@ -39,69 +47,102 @@ export function MessageDetailsSheet({ visible={visible} animationType="slide" presentationStyle="pageSheet" - onRequestClose={onClose} + onRequestClose={ + selectVisible + ? () => { + setSelectVisible(false); + } + : onClose + } > - - - - {content ? ( - - {content.copyableText ? ( - - - Copy message - - - ) : null} - - - - {content.roleLabel} - - - {content.sentTimeLabel ? ( - - - {content.sentTimeLabel} - - - ) : null} + {selectVisible ? ( + { + setSelectVisible(false); + }} + /> + ) : ( + + - {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} + + + + )} ); } 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..bd4e55c662 --- /dev/null +++ b/apps/mobile/src/components/agents/message-text-select-sheet.tsx @@ -0,0 +1,33 @@ +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 = { + text: string; + onClose: () => void; +}; + +/** + * 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({ text, onClose }: Readonly) { + const insets = useSafeAreaInsets(); + + return ( + + + + {text.length > 0 ? ( + + {text} + + ) : null} + + + + ); +}