Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<TestRenderer.ReactTestRenderer> {
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
);
});
});
29 changes: 22 additions & 7 deletions apps/mobile/src/components/agents/mono-scroll-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
};

/**
Expand All @@ -48,6 +51,7 @@ export function MonoScrollBlock({
maxLength,
textClassName,
containerClassName,
inTranscript = false,
}: Readonly<MonoScrollBlockProps>) {
const textSelectable = useTranscriptTextSelectable();
const { displayText, isTruncated } = prepareMonoScrollContent(content, maxLength);
Expand All @@ -62,6 +66,8 @@ export function MonoScrollBlock({
[displayText]
);

const contentClasses = 'shrink-0 self-start font-mono text-xs leading-4';

return (
<View className={containerClassName}>
<ScrollView
Expand All @@ -70,13 +76,22 @@ export function MonoScrollBlock({
// eslint-disable-next-line react-native/no-inline-styles -- measured height cannot be a Tailwind class
style={contentHeight === undefined ? undefined : { height: contentHeight }}
>
<Text
selectable={textSelectable}
onLayout={handleContentLayout}
className={cn('shrink-0 self-start font-mono text-xs leading-4', textClassName)}
>
{displayText}
</Text>
{inTranscript ? (
<Text
selectable={textSelectable}
onLayout={handleContentLayout}
className={cn(contentClasses, textClassName)}
>
{displayText}
</Text>
) : (
<SelectableText
onLayout={handleContentLayout}
className={cn(contentClasses, textClassName)}
>
{displayText}
</SelectableText>
)}
</ScrollView>
{isTruncated ? (
<Text accessibilityLabel="Content truncated" className="mt-1 text-xs text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}));
Expand Down Expand Up @@ -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);
});
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/components/agents/part-detail-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,9 +26,9 @@ function renderPartContent(part: Part | null): ReactNode {
}
if (isReasoningPart(part)) {
return (
<Text selectable className="text-sm leading-5 text-muted-foreground">
<SelectableText className="text-sm leading-5 text-muted-foreground">
{part.text}
</Text>
</SelectableText>
);
}
return null;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/preparation-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) {
content={step.outputTail}
containerClassName="rounded bg-secondary p-2"
textClassName="text-foreground"
inTranscript
/>
</>
) : null}
Expand Down
17 changes: 6 additions & 11 deletions apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -27,17 +26,13 @@ export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) {
<View className="gap-2">
{command.length > 0 ? (
<View className="rounded bg-neutral-100 px-2 py-1 dark:bg-neutral-900">
<Text selectable={textSelectable} className="font-mono text-xs leading-4 text-foreground">
$ {command}
</Text>
<SelectableText className="font-mono text-xs leading-4 text-foreground">
{commandText}
</SelectableText>
</View>
) : null}
{output ? <MonoScrollBlock content={output} textClassName="text-muted-foreground" /> : null}
{error ? (
<Text selectable={textSelectable} className="text-xs text-destructive">
{error}
</Text>
) : null}
{error ? <SelectableText className="text-xs text-destructive">{error}</SelectableText> : null}
</View>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(undefined),
};
});
vi.mock('../bubble-text-selection-context', () => ({
useTranscriptTextSelectable: () => true,
}));
Expand All @@ -20,6 +27,9 @@ vi.mock('react', async importOriginal => {
default: actual,
...actual,
useMemo: <T>(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,
};
});

Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down
10 changes: 2 additions & 8 deletions apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 : '';
Expand All @@ -63,11 +61,7 @@ export function EditToolCardBody({ part }: Readonly<{ part: ToolPart }>) {
return (
<View className="gap-2">
{body}
{error ? (
<Text selectable={textSelectable} className="text-xs text-destructive">
{error}
</Text>
) : null}
{error ? <SelectableText className="text-xs text-destructive">{error}</SelectableText> : null}
</View>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,7 +24,6 @@ function formatInput(input: Record<string, unknown>): 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;
Expand All @@ -39,11 +37,7 @@ export function GenericToolCardBody({ part }: Readonly<{ part: ToolPart }>) {
<MonoScrollBlock content={inputStr} textClassName="text-muted-foreground" />
) : null}
{output ? <MonoScrollBlock content={output} textClassName="text-foreground" /> : null}
{error ? (
<Text selectable={textSelectable} className="text-xs text-destructive">
{error}
</Text>
) : null}
{error ? <SelectableText className="text-xs text-destructive">{error}</SelectableText> : null}
</View>
);
}
Expand Down
11 changes: 2 additions & 9 deletions apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<View className="gap-2">
{output ? <MonoScrollBlock content={output} textClassName="text-foreground" /> : null}
{error ? (
<Text selectable={textSelectable} className="text-xs text-destructive">
{error}
</Text>
) : null}
{error ? <SelectableText className="text-xs text-destructive">{error}</SelectableText> : null}
</View>
);
}
Expand Down
Loading