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
19 changes: 17 additions & 2 deletions apps/mobile/src/components/agents/chat-markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ import {
} from './chat-link-actions';
import { formatLinkHost } from './markdown-link-confirm';
import { MarkdownText, type MarkdownTextProps } from './markdown-text';
import { performCopy } from './use-message-copy';

type ChatMarkdownTextProps = Omit<MarkdownTextProps, 'onLongPressLink' | 'onPressLink'>;
type ChatMarkdownTextProps = Omit<
MarkdownTextProps,
'onLongPressLink' | 'onPressLink' | 'onCopyCode'
>;

/** Sheet message: host then full href, so the host is visible above the URL. */
function sheetMessage(href: string): string {
Expand Down Expand Up @@ -120,7 +124,18 @@ export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
[bottom, prReviewEnabled, router, showActionSheetWithOptions, t]
);

// Code fences in the transcript copy through the shared clipboard helper, so
// success/failure feedback (haptic + toast) matches every other copy action.
const handleCopyCode = useCallback((code: string) => {
void performCopy(code);
}, []);

return (
<MarkdownText {...props} onLongPressLink={handleLongPressLink} onPressLink={handlePressLink} />
<MarkdownText
{...props}
onLongPressLink={handleLongPressLink}
onPressLink={handlePressLink}
onCopyCode={handleCopyCode}
Comment thread
iscekic marked this conversation as resolved.
/>
);
}
297 changes: 297 additions & 0 deletions apps/mobile/src/components/agents/code-block.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- token-color, truncation, and copy/long-press suites share the direct-invocation CodeBlock harness. */
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); same pattern as tool-diff-preview.test.ts */
import * as React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
Expand All @@ -19,6 +20,7 @@ const { useMonoScrollSheetMock } = vi.hoisted(() => ({
// RNGH ships Flow source that the node project cannot parse, so the horizontal
// ScrollView becomes a string element.
vi.mock('react-native', () => ({
Pressable: 'Pressable',
View: 'View',
Text: 'RNText',
}));
Expand Down Expand Up @@ -88,6 +90,58 @@ function truncatedMarkers(root: TestRenderer.ReactTestInstance): TestRenderer.Re
);
}

function byTestId(
root: TestRenderer.ReactTestInstance,
testID: string
): TestRenderer.ReactTestInstance[] {
return root.findAll(node => propOf(node, 'testID') === testID);
}

function firstByTestId(
root: TestRenderer.ReactTestInstance,
testID: string
): TestRenderer.ReactTestInstance {
const first = byTestId(root, testID)[0];
if (!first) {
throw new TypeError(`expected a node with testID ${testID}`);
}
return first;
}

function press(instance: TestRenderer.ReactTestInstance): void {
const onPress = propOf(instance, 'onPress');
if (typeof onPress !== 'function') {
throw new TypeError('expected an onPress handler');
}
(onPress as () => void)();
}

function longPress(instance: TestRenderer.ReactTestInstance): void {
const onLongPress = propOf(instance, 'onLongPress');
if (typeof onLongPress !== 'function') {
throw new TypeError('expected an onLongPress handler');
}
(onLongPress as () => void)();
}

function layout(instance: TestRenderer.ReactTestInstance, width: number, height: number): void {
const onLayout = propOf(instance, 'onLayout');
if (typeof onLayout !== 'function') {
throw new TypeError('expected an onLayout handler');
}
(
onLayout as (event: {
nativeEvent: { layout: { x: number; y: number; width: number; height: number } };
}) => void
)({
nativeEvent: { layout: { x: 0, y: 0, width, height } },
});
}

function pressables(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance[] {
return root.findAll(node => isMockedStringElement(node, 'Pressable'));
}

async function mount(element: React.ReactElement): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
Expand Down Expand Up @@ -220,3 +274,246 @@ describe('CodeBlock', () => {
await unmount(renderer);
});
});

describe('CodeBlock copy action', () => {
it('renders no copy affordance without an onCopyCode handler', async () => {
const renderer = await mount(blockElement());
expect(byTestId(renderer.root, 'code-block-copy-trigger')).toHaveLength(0);
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});

it('reveals the copy action on a single tap and hands back the full source', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ code: 'const x = 1;', onCopyCode }));

expect(byTestId(renderer.root, 'code-block-copy-trigger')).toHaveLength(1);
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);

act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-trigger'));
});
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(1);

act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-action'));
});
expect(onCopyCode).toHaveBeenCalledWith('const x = 1;');
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});

it('anchors the revealed action to the tap, so a tall fence shows it in view', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ code: 'x'.repeat(5000), onCopyCode }));
const trigger = firstByTestId(renderer.root, 'code-block-copy-trigger');
const onPress = propOf(trigger, 'onPress') as (event: {
nativeEvent: { locationY: number };
}) => void;

act(() => {
onPress({ nativeEvent: { locationY: 1234 } });
});

const action = firstByTestId(renderer.root, 'code-block-copy-action');
expect(propOf(action, 'style')).toEqual({ top: 1234 });
await unmount(renderer);
});

it('falls back to the block top for a synthetic press with no coordinates', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ onCopyCode }));

act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-trigger'));
});

const action = firstByTestId(renderer.root, 'code-block-copy-action');
expect(propOf(action, 'style')).toEqual({ top: 0 });
await unmount(renderer);
});

it('reserves the measured action width as a right gutter on the copy trigger', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ onCopyCode }));
const trigger = firstByTestId(renderer.root, 'code-block-copy-trigger');
expect(propOf(trigger, 'style')).toBeUndefined();

act(() => {
layout(firstByTestId(renderer.root, 'code-block-copy-measure'), 96, 28);
});

expect(propOf(firstByTestId(renderer.root, 'code-block-copy-trigger'), 'style')).toEqual({
paddingRight: 104,
});
await unmount(renderer);
});

it('measures the label without leaking a hidden copy button to assistive tech', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ onCopyCode }));
const measure = firstByTestId(renderer.root, 'code-block-copy-measure');
expect(propOf(measure, 'accessibilityElementsHidden')).toBe(true);
expect(propOf(measure, 'importantForAccessibility')).toBe('no-hide-descendants');
expect(propOf(measure, 'pointerEvents')).toBe('none');
await unmount(renderer);
});

it('leaves the code at full width without a copy handler', async () => {
const renderer = await mount(blockElement());
expect(byTestId(renderer.root, 'code-block-copy-measure')).toHaveLength(0);
expect(byTestId(renderer.root, 'code-block-copy-trigger')).toHaveLength(0);
await unmount(renderer);
});

it('reserves the measured gutter on the scroll-mode trigger so the action clears the glyphs', async () => {
const track = vi.fn(() => () => undefined);
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(withSheet('scroll', track, blockElement({ onCopyCode })));

act(() => {
layout(firstByTestId(renderer.root, 'code-block-copy-measure'), 72, 28);
});

expect(propOf(firstByTestId(renderer.root, 'code-block-copy-trigger'), 'style')).toEqual({
paddingRight: 80,
});
await unmount(renderer);
});

it('right-aligns the revealed action inside the reserved gutter', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ onCopyCode }));

act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-trigger'));
});

const action = firstByTestId(renderer.root, 'code-block-copy-action');
expect(propOf(action, 'className')).toContain('right-0');
await unmount(renderer);
});

it('copies the source before the display cap, not the truncated slice', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const full = 'x'.repeat(300);
const renderer = await mount(blockElement({ code: full, maxLength: 50, onCopyCode }));

act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-trigger'));
});
act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-action'));
});
expect(onCopyCode).toHaveBeenCalledWith(full);
await unmount(renderer);
});

it('exposes a copy accessibility action on the code text', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ onCopyCode }));
const parent = codeParent(renderer.root);
expect(parent).toBeDefined();
const onAccessibilityAction = propOf(parent, 'onAccessibilityAction');
expect(typeof onAccessibilityAction).toBe('function');

act(() => {
(onAccessibilityAction as (event: { nativeEvent: { actionName: string } }) => void)({
nativeEvent: { actionName: 'copyCode' },
});
});
expect(onCopyCode).toHaveBeenCalledWith('const x = 1;');
await unmount(renderer);
});

it('offers no copy action for an empty fence', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ code: '', onCopyCode }));
expect(byTestId(renderer.root, 'code-block-copy-trigger')).toHaveLength(0);
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});

it('hides a revealed action when the code changes', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const renderer = await mount(blockElement({ code: 'first', onCopyCode }));
act(() => {
press(firstByTestId(renderer.root, 'code-block-copy-trigger'));
});
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(1);

act(() => {
renderer.update(blockElement({ code: 'second', onCopyCode }));
});
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});
});

describe('CodeBlock copy trigger long-press forwarding', () => {
it('forwards a long press on the trigger instead of revealing the action', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const onLongPressCode = vi.fn<() => void>();
const renderer = await mount(blockElement({ onCopyCode, onLongPressCode }));
const trigger = firstByTestId(renderer.root, 'code-block-copy-trigger');

act(() => {
longPress(trigger);
});

expect(onLongPressCode).toHaveBeenCalledTimes(1);
expect(onCopyCode).not.toHaveBeenCalled();
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});

it('hides a revealed action when a long press opens message details', async () => {
const onCopyCode = vi.fn<(code: string) => void>();
const onLongPressCode = vi.fn<() => void>();
const renderer = await mount(blockElement({ onCopyCode, onLongPressCode }));
const trigger = firstByTestId(renderer.root, 'code-block-copy-trigger');

act(() => {
press(trigger);
});
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(1);

act(() => {
longPress(trigger);
});

expect(onLongPressCode).toHaveBeenCalledTimes(1);
expect(byTestId(renderer.root, 'code-block-copy-action')).toHaveLength(0);
await unmount(renderer);
});

it('mounts no responder wrapper without a copy handler', async () => {
const renderer = await mount(blockElement());
expect(pressables(renderer.root)).toHaveLength(0);
await unmount(renderer);
});

it('mounts no responder wrapper without a copy handler in sheet scroll mode', async () => {
const track = vi.fn(() => () => undefined);
const renderer = await mount(withSheet('scroll', track, blockElement()));
expect(pressables(renderer.root)).toHaveLength(0);
await unmount(renderer);
});

it('forwards a long press on the scroll-mode trigger', async () => {
const track = vi.fn(() => () => undefined);
const onCopyCode = vi.fn<(code: string) => void>();
const onLongPressCode = vi.fn<() => void>();
const renderer = await mount(
withSheet('scroll', track, blockElement({ onCopyCode, onLongPressCode }))
);
const trigger = firstByTestId(renderer.root, 'code-block-copy-trigger');

act(() => {
longPress(trigger);
});

expect(onLongPressCode).toHaveBeenCalledTimes(1);
await unmount(renderer);
});
});
Loading