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
62 changes: 0 additions & 62 deletions apps/mobile/src/components/agents/markdown-viewer-modal.tsx

This file was deleted.

90 changes: 90 additions & 0 deletions apps/mobile/src/components/agents/read-markdown-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';

import { ReadMarkdownBody } from './read-markdown-body';

vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('./bubble-text-selection-context', () => ({
useTranscriptTextSelectable: () => true,
}));
vi.mock('./chat-markdown-text', () => ({ ChatMarkdownText: 'ChatMarkdownText' }));

/** A markdown body over 2000 chars — the removed inline cap. */
const longText = `${'# Heading\n'.repeat(50)}\n`.repeat(20);

function findAll(
node: unknown,
predicate: (el: React.ReactElement) => boolean
): React.ReactElement[] {
const matches: React.ReactElement[] = [];
function walk(value: unknown): void {
if (value == null || typeof value === 'string' || typeof value === 'number') {
return;
}
if (Array.isArray(value)) {
for (const child of value) {
walk(child);
}
return;
}
if (React.isValidElement(value)) {
if (predicate(value)) {
matches.push(value);
}
const props = value.props as Record<string, unknown>;
// Walk the rendered output of function components so their
// children are visible to predicate matching.
if (typeof value.type === 'function') {
walk((value.type as React.FunctionComponent<unknown>)(props));
}
walk(props.children);
}
}
walk(node);
return matches;
}

function findByType(root: React.ReactElement, type: string): React.ReactElement[] {
return findAll(root, el => el.type === type);
}

describe('ReadMarkdownBody', () => {
it('renders the full markdown with no nested tap action', () => {
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ReadMarkdownBody({
body: { text: longText, footer: undefined },
}) as unknown as React.ReactElement;
const markdown = findByType(root, 'ChatMarkdownText');
expect(markdown).toHaveLength(1);
const markdownElement = markdown[0];
if (!markdownElement) {
throw new Error('markdown not found');
}
expect((markdownElement.props as { value?: unknown }).value).toBe(longText);
expect((markdownElement.props as { selectable?: unknown }).selectable).toBe(true);
expect(findByType(root, 'Pressable')).toHaveLength(0);
});

it('renders the footer text when present', () => {
const footer = 'lines 201–400 of 1,450';
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ReadMarkdownBody({
body: { text: '# Title', footer },
}) as unknown as React.ReactElement;
const texts = findByType(root, 'Text');
expect(texts.some(el => (el.props as { children?: unknown }).children === footer)).toBe(true);
});

it('shows the empty-file line and no markdown for an empty body', () => {
// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ReadMarkdownBody({
body: { text: '', footer: undefined },
}) as unknown as React.ReactElement;
const texts = findByType(root, 'Text');
expect(
texts.some(el => (el.props as { children?: unknown }).children === 'This file is empty.')
).toBe(true);
expect(findByType(root, 'ChatMarkdownText')).toHaveLength(0);
});
});
27 changes: 27 additions & 0 deletions apps/mobile/src/components/agents/read-markdown-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { View } from 'react-native';

import { Text } from '@/components/ui/text';

import { useTranscriptTextSelectable } from './bubble-text-selection-context';
import { ChatMarkdownText } from './chat-markdown-text';
import { type MarkdownBody } from './read-tool-markdown';

/**
* Full markdown body of a read tool part, rendered directly in the detail sheet.
* The sheet scrolls, so the complete file renders here — no inline cap, no nested
* full-screen reader.
*/
export function ReadMarkdownBody({ body }: Readonly<{ body: MarkdownBody }>) {
const textSelectable = useTranscriptTextSelectable();

if (body.text === '') {
return <Text className="text-xs text-muted-foreground">This file is empty.</Text>;
}

return (
<View className="gap-1">
<ChatMarkdownText value={body.text} selectable={textSelectable} />
{body.footer ? <Text className="text-xs text-muted-foreground">{body.footer}</Text> : null}
</View>
);
}
49 changes: 0 additions & 49 deletions apps/mobile/src/components/agents/read-markdown-preview.tsx

This file was deleted.

75 changes: 19 additions & 56 deletions apps/mobile/src/components/agents/read-tool-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { describe, expect, it } from 'vitest';
import {
balanceCodeFences,
isMarkdownPath,
MARKDOWN_INLINE_MAX_CHARS,
parseReadFileDisplay,
parseReadOutputFallback,
resolveMarkdownPreview,
resolveMarkdownBody,
} from './read-tool-markdown';

// F1 complete 3-line read (note the trailing space on line 2)
Expand Down Expand Up @@ -233,7 +232,7 @@ describe('balanceCodeFences', () => {
});
});

describe('resolveMarkdownPreview', () => {
describe('resolveMarkdownBody', () => {
it('prefers display text over output when both exist', () => {
const part = makeCompletedPart({
output: COMPLETE,
Expand All @@ -248,20 +247,19 @@ describe('resolveMarkdownPreview', () => {
},
},
});
const preview = resolveMarkdownPreview(part);
expect(preview?.text).toBe('# From display');
expect(preview?.text).not.toMatch(/^\d+: /m);
expect(preview?.text).not.toContain('1: ');
const body = resolveMarkdownBody(part);
expect(body?.text).toBe('# From display');
expect(body?.text).not.toMatch(/^\d+: /m);
expect(body?.text).not.toContain('1: ');
});

it('falls back to output when display.text is absent', () => {
const part = makeCompletedPart({
output: COMPLETE,
metadata: {},
});
const preview = resolveMarkdownPreview(part);
expect(preview?.text).toBe('# Title\n\n- item');
expect(preview?.path).toBe('/repo/README.md');
const body = resolveMarkdownBody(part);
expect(body?.text).toBe('# Title\n\n- item');
});

it('returns undefined for a non-completed state', () => {
Expand All @@ -278,7 +276,7 @@ describe('resolveMarkdownPreview', () => {
time: { start: 0 },
},
};
expect(resolveMarkdownPreview(part)).toBeUndefined();
expect(resolveMarkdownBody(part)).toBeUndefined();
});

it('omits the footer for a complete untruncated read', () => {
Expand All @@ -295,7 +293,7 @@ describe('resolveMarkdownPreview', () => {
},
},
});
expect(resolveMarkdownPreview(part)?.footer).toBeUndefined();
expect(resolveMarkdownBody(part)?.footer).toBeUndefined();
});

it('formats a windowed footer with en dash and thousands separator', () => {
Expand All @@ -313,22 +311,22 @@ describe('resolveMarkdownPreview', () => {
},
},
});
expect(resolveMarkdownPreview(part)?.footer).toBe('lines 201–400 of 1,450');
expect(resolveMarkdownBody(part)?.footer).toBe('lines 201–400 of 1,450');
});

it('formats a byte-capped footer ending with (truncated)', () => {
const part = makeCompletedPart({
output: CAPPED,
metadata: {},
});
const footer = resolveMarkdownPreview(part)?.footer;
const footer = resolveMarkdownBody(part)?.footer;
expect(footer).toBeDefined();
expect(footer?.endsWith('(truncated)')).toBe(true);
});

it('caps inline text over the char limit at a newline boundary', () => {
it('keeps the full markdown over 2000 chars without truncation', () => {
const longText = `${'a'.repeat(100)}\n`.repeat(30);
expect(longText.length).toBeGreaterThan(MARKDOWN_INLINE_MAX_CHARS);
expect(longText.length).toBeGreaterThan(2000);
const part = makeCompletedPart({
metadata: {
display: {
Expand All @@ -341,42 +339,8 @@ describe('resolveMarkdownPreview', () => {
},
},
});
const preview = resolveMarkdownPreview(part);
expect(preview).toBeDefined();
if (!preview) {
return;
}
expect(preview.inlineTruncated).toBe(true);
expect(preview.inlineText.length).toBeLessThan(preview.text.length);
expect(preview.inlineText.length).toBeLessThanOrEqual(MARKDOWN_INLINE_MAX_CHARS);
// backed off to last newline in the 2000-char slice (content is 'a'*100 + '\n' repeats)
expect(preview.inlineText.endsWith('a')).toBe(true);
expect(preview.text.startsWith(preview.inlineText)).toBe(true);
});

it('balances an open fence introduced by the inline slice', () => {
const openFence = '```ts\n';
const filler = 'x'.repeat(MARKDOWN_INLINE_MAX_CHARS - openFence.length + 50);
const text = `${openFence}${filler}\n\`\`\``;
const part = makeCompletedPart({
metadata: {
display: {
type: 'file',
path: '/repo/CODE.md',
text,
lineStart: 1,
lineEnd: 3,
totalLines: 3,
},
},
});
const preview = resolveMarkdownPreview(part);
expect(preview?.inlineTruncated).toBe(true);
expect(preview?.inlineText.endsWith('```')).toBe(true);
const fenceCount = (preview?.inlineText.split('\n') ?? []).filter(line =>
/^\s*```/.test(line)
).length;
expect(fenceCount % 2).toBe(0);
const body = resolveMarkdownBody(part);
expect(body?.text).toBe(balanceCodeFences(longText));
});

it('returns empty text for an empty file display', () => {
Expand All @@ -393,9 +357,8 @@ describe('resolveMarkdownPreview', () => {
},
},
});
const preview = resolveMarkdownPreview(part);
expect(preview?.text).toBe('');
expect(preview?.inlineText).toBe('');
expect(preview?.footer).toBeUndefined();
const body = resolveMarkdownBody(part);
expect(body?.text).toBe('');
expect(body?.footer).toBeUndefined();
});
});
Loading