From 74bf1bdfe94e03a731aff3a94eaa37bf9e3454f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 5 Aug 2026 21:36:26 +0200 Subject: [PATCH 1/2] feat(mobile): add native selectable text --- .../part-detail-sheet-host.mounted.test.tsx | 8 ++-- .../components/agents/part-detail-sheet.tsx | 5 +- .../src/components/ui/selectable-text.tsx | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/components/ui/selectable-text.tsx diff --git a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx index a9b4f5bca47..826ca90d2d9 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx @@ -26,6 +26,9 @@ vi.mock('@/components/sheet-header', () => ({ vi.mock('@/components/ui/text', () => ({ Text: 'Text', })); +vi.mock('@/components/ui/selectable-text', () => ({ + SelectableText: 'SelectableText', +})); vi.mock('./tool-part-detail-body', () => ({ ToolPartDetailBody: 'ToolPartDetailBody', })); @@ -188,9 +191,8 @@ describe('PartDetailSheetHost mounted', () => { const reasoningTexts = renderer.root.findAll( node => typeof node.type === 'string' && - (node.type as string) === 'Text' && - propOf(node, 'children') === 'working through it' && - propOf(node, 'selectable') === true + (node.type as string) === 'SelectableText' && + propOf(node, 'children') === 'working through it' ); expect(reasoningTexts).toHaveLength(1); }); diff --git a/apps/mobile/src/components/agents/part-detail-sheet.tsx b/apps/mobile/src/components/agents/part-detail-sheet.tsx index 226fa1580b5..ae8bdb4a8e8 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet.tsx @@ -4,6 +4,7 @@ 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'; import { Text } from '@/components/ui/text'; import { getPartDetailTitle } from './part-detail-model'; @@ -25,9 +26,9 @@ function renderPartContent(part: Part | null): ReactNode { } if (isReasoningPart(part)) { return ( - + {part.text} - + ); } return null; diff --git a/apps/mobile/src/components/ui/selectable-text.tsx b/apps/mobile/src/components/ui/selectable-text.tsx new file mode 100644 index 00000000000..72738c25f09 --- /dev/null +++ b/apps/mobile/src/components/ui/selectable-text.tsx @@ -0,0 +1,48 @@ +import { type LayoutChangeEvent, TextInput } from 'react-native'; +import { useContext } from 'react'; + +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +type SelectableTextProps = { + children: string; + className?: string; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +/** + * Read-only multiline text that keeps the platform's native selection on iOS. + * + * `Text selectable` cannot deliver it in React Native 0.86: + * `RCTParagraphComponentView.handleLongPress:` presents a `UIEditMenuInteraction` + * without ever calling `becomeFirstResponder`, so the first long press shows an + * empty menu (`RCTParagraphComponentView.mm:311`), and `canPerformAction:` + * validates only `copy:` (`:336`), so selection handles and Share never appear. + * A read-only multiline `TextInput` is a real `UITextView`, which owns its + * responder state and gives the platform's own callout on the first press. + * + * The text flows through `value`, not `defaultValue`, because the sheet + * re-resolves a streaming part from the live `messages` prop on every render. + * The repo rule that bans `value` plus state for editable iOS inputs does not + * apply: this input is read-only and has no state. + * + * While the text changes under an active selection, UIKit resets the selection; + * that is the platform's own behavior. When the part vanishes, the parent swaps + * to its "unavailable" line and this input unmounts, which also ends the + * selection. Neither case needs code here. + */ +export function SelectableText({ children, className, onLayout }: Readonly) { + const textClass = useContext(TextClassContext); + return ( + + ); +} From 998637bf3650b4da44bb01057cf1c8f43ad1f3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 5 Aug 2026 21:36:32 +0200 Subject: [PATCH 2/2] feat(mobile): enable selection across tool sheets --- .../agents/mono-scroll-block.mounted.test.tsx | 74 ++++++++++++++ .../components/agents/mono-scroll-block.tsx | 29 ++++-- .../components/agents/preparation-group.tsx | 1 + .../agents/tool-cards/bash-tool-card.tsx | 17 ++-- .../agents/tool-cards/edit-tool-card.test.ts | 19 +++- .../agents/tool-cards/edit-tool-card.tsx | 10 +- .../agents/tool-cards/generic-tool-card.tsx | 10 +- .../agents/tool-cards/glob-tool-card.tsx | 11 +-- .../agents/tool-cards/grep-tool-card.tsx | 11 +-- .../agents/tool-cards/list-tool-card.tsx | 11 +-- .../agents/tool-cards/read-tool-card.tsx | 10 +- .../agents/tool-cards/task-tool-card.tsx | 11 +-- .../agents/tool-cards/todo-tool-card.tsx | 11 +-- .../tool-cards/tool-card-output-cap.test.ts | 21 +++- .../tool-cards/web-search-tool-card.tsx | 11 +-- .../agents/tool-cards/write-tool-card.test.ts | 18 +++- .../agents/tool-cards/write-tool-card.tsx | 10 +- .../agents/tool-part-detail-body.test.ts | 6 +- .../ui/selectable-text.mounted.test.tsx | 96 +++++++++++++++++++ 19 files changed, 271 insertions(+), 116 deletions(-) create mode 100644 apps/mobile/src/components/agents/mono-scroll-block.mounted.test.tsx create mode 100644 apps/mobile/src/components/ui/selectable-text.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/mono-scroll-block.mounted.test.tsx b/apps/mobile/src/components/agents/mono-scroll-block.mounted.test.tsx new file mode 100644 index 00000000000..0118bc70fad --- /dev/null +++ b/apps/mobile/src/components/agents/mono-scroll-block.mounted.test.tsx @@ -0,0 +1,74 @@ +/* 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 { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { MonoScrollBlock } from './mono-scroll-block'; + +vi.mock('react-native', () => ({ + View: 'View', +})); +vi.mock('react-native-gesture-handler', () => ({ + ScrollView: 'ScrollView', +})); +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); +vi.mock('@/components/ui/selectable-text', () => ({ + SelectableText: 'SelectableText', +})); +vi.mock('./bubble-text-selection-context', () => ({ + useTranscriptTextSelectable: () => false, +})); + +async function renderBlock(props: { + content: string; + maxLength?: number; + inTranscript?: boolean; +}): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(MonoScrollBlock, props)); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('MonoScrollBlock mounted', () => { + it('defaults to SelectableText and wires onLayout for the height pin', async () => { + const content = 'line one\nline two'; + const renderer = await renderBlock({ content }); + + const selectableHosts = renderer.root.findAll( + node => (node.type as string) === 'SelectableText' + ); + expect(selectableHosts).toHaveLength(1); + expect(selectableHosts[0]?.props.children).toBe(content); + // The height pin measures from the content's layout, so the sheet branch + // must forward onLayout; a later edit dropping it would break the pin. + expect(typeof selectableHosts[0]?.props.onLayout).toBe('function'); + + // The sheet branch must not render the transcript Text. + expect(renderer.root.findAll(node => (node.type as string) === 'Text')).toHaveLength(0); + }); + + it('keeps the transcript on Text when inTranscript is set', async () => { + const content = 'transcript output'; + const renderer = await renderBlock({ content, inTranscript: true }); + + const textHosts = renderer.root.findAll( + node => (node.type as string) === 'Text' && node.props.children === content + ); + expect(textHosts).toHaveLength(1); + // A UITextView must never land inside the transcript FlashList. + expect(renderer.root.findAll(node => (node.type as string) === 'SelectableText')).toHaveLength( + 0 + ); + }); +}); diff --git a/apps/mobile/src/components/agents/mono-scroll-block.tsx b/apps/mobile/src/components/agents/mono-scroll-block.tsx index e9e58a8af91..154e8b8eee4 100644 --- a/apps/mobile/src/components/agents/mono-scroll-block.tsx +++ b/apps/mobile/src/components/agents/mono-scroll-block.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from 'react'; import { type LayoutChangeEvent, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import { SelectableText } from '@/components/ui/selectable-text'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; @@ -22,6 +23,8 @@ type MonoScrollBlockProps = { textClassName?: string; /** Optional chrome around the scroller (e.g. rounded bg on preparation output). */ containerClassName?: string; + /** True for the transcript caller: keeps the `Text` path byte-identical there. */ + inTranscript?: boolean; }; /** @@ -48,6 +51,7 @@ export function MonoScrollBlock({ maxLength, textClassName, containerClassName, + inTranscript = false, }: Readonly) { const textSelectable = useTranscriptTextSelectable(); const { displayText, isTruncated } = prepareMonoScrollContent(content, maxLength); @@ -62,6 +66,8 @@ export function MonoScrollBlock({ [displayText] ); + const contentClasses = 'shrink-0 self-start font-mono text-xs leading-4'; + return ( - - {displayText} - + {inTranscript ? ( + + {displayText} + + ) : ( + + {displayText} + + )} {isTruncated ? ( diff --git a/apps/mobile/src/components/agents/preparation-group.tsx b/apps/mobile/src/components/agents/preparation-group.tsx index b57a28fea4c..a6d24406bef 100644 --- a/apps/mobile/src/components/agents/preparation-group.tsx +++ b/apps/mobile/src/components/agents/preparation-group.tsx @@ -145,6 +145,7 @@ function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) { content={step.outputTail} containerClassName="rounded bg-secondary p-2" textClassName="text-foreground" + inTranscript /> ) : null} diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx index 70fe73cdbbb..891ed271bd9 100644 --- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Terminal } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,9 +15,9 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * pending/running status line live in `ToolPartDetailBody`. */ export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const command = typeof input.command === 'string' ? input.command : ''; + const commandText = `$ ${command}`; const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; @@ -27,17 +26,13 @@ export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) { {command.length > 0 ? ( - - $ {command} - + + {commandText} + ) : null} {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts index ea9a5c9d0bb..14ae98f006f 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts @@ -5,9 +5,16 @@ import { type ToolDiffModel } from '../tool-diff-model'; import { EditToolCard, EditToolCardBody } from './edit-tool-card'; import * as React from 'react'; -vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-native', () => ({ View: 'View', TextInput: 'TextInput' })); vi.mock('lucide-react-native', () => ({ Pencil: 'Pencil' })); -vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +// Real context so the shared `SelectableText` can call `useContext(TextClassContext)`. +vi.mock('@/components/ui/text', async () => { + const { createContext } = await import('react'); + return { + Text: 'Text', + TextClassContext: createContext(undefined), + }; +}); vi.mock('../bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, })); @@ -20,6 +27,9 @@ vi.mock('react', async importOriginal => { default: actual, ...actual, useMemo: (fn: () => T): T => fn(), + // `SelectableText` is invoked directly by the pure walker, outside a React + // render, so the real `useContext` would throw on the null dispatcher. + useContext: () => undefined, }; }); @@ -278,8 +288,7 @@ describe('EditToolCardBody — diff preview routing', () => { const texts = findAll( root, el => - el.type === 'Text' && - (el.props as { children?: string }).children === 'something went wrong' + el.type === 'TextInput' && (el.props as { value?: string }).value === 'something went wrong' ); expect(texts).toHaveLength(1); }); @@ -297,7 +306,7 @@ describe('EditToolCardBody — diff preview routing', () => { }) as unknown as React.ReactElement; const texts = findAll( root, - el => el.type === 'Text' && (el.props as { children?: string }).children === 'edit failed' + el => el.type === 'TextInput' && (el.props as { value?: string }).value === 'edit failed' ); expect(texts).toHaveLength(1); }); diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx index 966cfd48de5..4c04161d9a8 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx @@ -3,9 +3,8 @@ import { View } from 'react-native'; import { Pencil } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -44,7 +43,6 @@ function EditFallbackBody({ * pending/running status line live in `ToolPartDetailBody`. */ export function EditToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const oldString = typeof input.oldString === 'string' ? input.oldString : ''; const newString = typeof input.newString === 'string' ? input.newString : ''; @@ -63,11 +61,7 @@ export function EditToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( {body} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx index b18d1ffa03a..0d95f33d5f9 100644 --- a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Plug } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -25,7 +24,6 @@ function formatInput(input: Record): string { * live in `ToolPartDetailBody`. */ export function GenericToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const output = part.state.status === 'completed' ? part.state.output : undefined; @@ -39,11 +37,7 @@ export function GenericToolCardBody({ part }: Readonly<{ part: ToolPart }>) { ) : null} {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx index ba6cd1b2c73..d737fa0a0c3 100644 --- a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Search } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,19 +15,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * and the pending/running status line live in `ToolPartDetailBody`. */ export function GlobToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx index 2c9cdd5b521..dff7db71b1f 100644 --- a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { FileSearch } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,19 +15,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * and the pending/running status line live in `ToolPartDetailBody`. */ export function GrepToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx index eb49e9fd5d1..f245ebc578c 100644 --- a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { FolderOpen } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,19 +15,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * and the pending/running status line live in `ToolPartDetailBody`. */ export function ListToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx index 04e719bd6f6..a4af5bee0b1 100644 --- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Eye } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -20,7 +19,6 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * detail sheet — the pending/running status line lives in `ToolPartDetailBody`. */ export function ReadToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : ''; @@ -37,11 +35,7 @@ export function ReadToolCardBody({ part }: Readonly<{ part: ToolPart }>) { {markdownPreview === undefined && !hasImages && output ? ( ) : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx index a9adef67b88..a87d2ed2701 100644 --- a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Cpu } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,19 +15,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * live in `ToolPartDetailBody`. */ export function TaskToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx index 90ff0899e66..7dedfaf9ddf 100644 --- a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { ListTodo } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -16,19 +15,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * pending/running status line live in `ToolPartDetailBody`. */ export function TodoToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts b/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts index 04a9df72a43..3a600a5cc13 100644 --- a/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts @@ -13,7 +13,17 @@ import { TodoToolCardBody } from './todo-tool-card'; import { WebSearchToolCardBody } from './web-search-tool-card'; import { prepareMonoScrollContent } from '../mono-scroll-block-model'; -vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-native', () => ({ View: 'View', TextInput: 'TextInput' })); +vi.mock('react', async importOriginal => { + const actual = await importOriginal(); + return { + default: actual, + ...actual, + // `SelectableText` is invoked directly by the pure walker, outside a React + // render, so the real `useContext` would throw on the null dispatcher. + useContext: () => undefined, + }; +}); vi.mock('lucide-react-native', () => ({ Terminal: 'Terminal', Search: 'Search', @@ -25,7 +35,14 @@ vi.mock('lucide-react-native', () => ({ Globe: 'Globe', Plug: 'Plug', })); -vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +// Real context so the shared `SelectableText` can call `useContext(TextClassContext)`. +vi.mock('@/components/ui/text', async () => { + const { createContext } = await import('react'); + return { + Text: 'Text', + TextClassContext: createContext(undefined), + }; +}); vi.mock('../bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, })); diff --git a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx index 47b7e705058..c73c3664ace 100644 --- a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx @@ -2,9 +2,8 @@ import { View } from 'react-native'; import { Globe } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -17,19 +16,13 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; * `ToolPartDetailBody`. */ export function WebSearchToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); - const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( {output ? : null} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts index cac1ce6e095..bf4a6561db2 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts @@ -5,9 +5,16 @@ import { type ToolDiffModel } from '../tool-diff-model'; import { WriteToolCard, WriteToolCardBody } from './write-tool-card'; import * as React from 'react'; -vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-native', () => ({ View: 'View', TextInput: 'TextInput' })); vi.mock('lucide-react-native', () => ({ FilePlus: 'FilePlus' })); -vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +// Real context so the shared `SelectableText` can call `useContext(TextClassContext)`. +vi.mock('@/components/ui/text', async () => { + const { createContext } = await import('react'); + return { + Text: 'Text', + TextClassContext: createContext(undefined), + }; +}); vi.mock('../bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, })); @@ -20,6 +27,9 @@ vi.mock('react', async importOriginal => { default: actual, ...actual, useMemo: (fn: () => T): T => fn(), + // `SelectableText` is invoked directly by the pure walker, outside a React + // render, so the real `useContext` would throw on the null dispatcher. + useContext: () => undefined, }; }); @@ -267,7 +277,7 @@ describe('WriteToolCardBody — diff preview routing', () => { }) as unknown as React.ReactElement; const texts = findAll( root, - el => el.type === 'Text' && (el.props as { children?: string }).children === 'write failed' + el => el.type === 'TextInput' && (el.props as { value?: string }).value === 'write failed' ); expect(texts).toHaveLength(1); }); @@ -280,7 +290,7 @@ describe('WriteToolCardBody — diff preview routing', () => { }) as unknown as React.ReactElement; const texts = findAll( root, - el => el.type === 'Text' && (el.props as { children?: string }).children === 'write error' + el => el.type === 'TextInput' && (el.props as { value?: string }).value === 'write error' ); expect(texts).toHaveLength(1); }); diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx index df568e01123..44d61db18bd 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx @@ -3,9 +3,8 @@ import { View } from 'react-native'; import { FilePlus } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { Text } from '@/components/ui/text'; +import { SelectableText } from '@/components/ui/selectable-text'; -import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; @@ -20,7 +19,6 @@ import { ToolDiffPreview } from '../tool-diff-preview'; * `ToolPartDetailBody`. */ export function WriteToolCardBody({ part }: Readonly<{ part: ToolPart }>) { - const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const content = typeof input.content === 'string' ? input.content : ''; @@ -38,11 +36,7 @@ export function WriteToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( {body} - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} ); } diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts index 00edb619688..87d0546afd1 100644 --- a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts +++ b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts @@ -20,6 +20,7 @@ import { BashToolCardBody as RealBashToolCardBody } from './tool-cards/bash-tool vi.mock('react-native', () => ({ View: 'View' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/selectable-text', () => ({ SelectableText: 'SelectableText' })); vi.mock('lucide-react-native', () => ({ Terminal: 'Terminal' })); vi.mock('./bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, @@ -292,7 +293,10 @@ describe('BashToolCardBody streaming contract', () => { it('renders the $ command block while running with a short command', () => { // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call const root = RealBashToolCardBody({ part: makeToolPart('bash', runningState) }); - const commands = findAll(root, el => el.type === 'Text' && renderedText(el) === '$ echo hi'); + const commands = findAll( + root, + el => el.type === 'SelectableText' && renderedText(el) === '$ echo hi' + ); expect(commands).toHaveLength(1); }); }); diff --git a/apps/mobile/src/components/ui/selectable-text.mounted.test.tsx b/apps/mobile/src/components/ui/selectable-text.mounted.test.tsx new file mode 100644 index 00000000000..ab679012d74 --- /dev/null +++ b/apps/mobile/src/components/ui/selectable-text.mounted.test.tsx @@ -0,0 +1,96 @@ +/* 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 { createElement } from 'react'; +import { type LayoutChangeEvent } from 'react-native'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { SelectableText } from './selectable-text'; + +vi.mock('react-native', () => ({ + TextInput: 'TextInput', +})); +// The real `@/components/ui/text` loads `@rn-primitives/slot`, whose node_modules +// `.mjs` contains JSX that this pipeline cannot transform. Provide a real context +// so `useContext(TextClassContext)` in `SelectableText` still resolves. +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { + Text: 'Text', + TextClassContext: React.createContext(undefined), + }; +}); + +function findTextInput( + renderer: TestRenderer.ReactTestRenderer +): TestRenderer.ReactTestInstance | undefined { + const hosts = renderer.root.findAll(node => (node.type as string) === 'TextInput'); + return hosts[0]; +} + +/** Sentinel whose identity the test asserts reaches the TextInput host. */ +const sentinelOnLayout = () => undefined; + +async function renderSelectableText( + children: string, + onLayout?: (event: LayoutChangeEvent) => void +): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create( + // eslint-disable-next-line react/no-children-prop -- tsgo requires `children` in the props object, not the third argument. + createElement(SelectableText, onLayout ? { children, onLayout } : { children }) + ); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('SelectableText mounted', () => { + it('renders a read-only multiline TextInput with the passed text as value', async () => { + const renderer = await renderSelectableText('hello world'); + + const input = findTextInput(renderer); + expect(input).toBeDefined(); + expect(input?.props.editable).toBe(false); + expect(input?.props.multiline).toBe(true); + expect(input?.props.scrollEnabled).toBe(false); + // Native selection must not be suppressed or overridden. + expect(input?.props.contextMenuHidden).toBeUndefined(); + expect(input?.props.accessibilityRole).toBeUndefined(); + // Text flows through `value`, not `defaultValue` (streaming parts re-resolve). + expect(input?.props.value).toBe('hello world'); + }); + + it('keeps the shared Text weight and zero padding in the class string', async () => { + const renderer = await renderSelectableText('styled'); + + const className = findTextInput(renderer)?.props.className as string; + expect(className).toContain('font-medium'); + expect(className).toContain('p-0'); + }); + + it('updates the value when children change instead of freezing the first value', async () => { + const renderer = await renderSelectableText('first'); + expect(findTextInput(renderer)?.props.value).toBe('first'); + + await act(async () => { + await Promise.resolve(); + // eslint-disable-next-line react/no-children-prop -- tsgo requires `children` in the props object, not the third argument. + renderer.update(createElement(SelectableText, { children: 'second' })); + }); + expect(findTextInput(renderer)?.props.value).toBe('second'); + }); + + it('forwards the caller onLayout to the TextInput host', async () => { + const renderer = await renderSelectableText('measured', sentinelOnLayout); + + // Same reference, so the mono block height pin stays wired to real layout. + expect(findTextInput(renderer)?.props.onLayout).toBe(sentinelOnLayout); + }); +});