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
837 changes: 771 additions & 66 deletions apps/ui/patches/react-native-enriched-markdown+0.5.0.patch

Large diffs are not rendered by default.

88 changes: 84 additions & 4 deletions apps/ui/sources/components/markdown/MarkdownBlockView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { MarkdownCodeBlock } from './MarkdownCodeBlock';
import type { StreamingTextRevealPreset } from './streaming/streamingTextRevealConfig';
import { CopiedPill } from '@/components/ui/copy/CopiedPill';
import { useTemporaryCopyFeedback } from '@/components/ui/copy/useTemporaryCopyFeedback';
import { EnrichedMarkdownTextAdapter } from './enriched/EnrichedMarkdownTextAdapter';
import type { MarkdownRenderingProfile } from './rendering/MarkdownRenderingProfile';

// Option type for callback
export type Option = {
Expand All @@ -30,9 +32,11 @@ type MarkdownBlockViewProps = {
onOptionLongPress?: OptionLongPressHandler;
onLinkPress?: (url: string) => boolean | void;
textStyle?: StyleProp<TextStyle>;
profile: MarkdownRenderingProfile;
variant: 'default' | 'thinking';
streamingReveal: boolean;
streamingRevealPreset?: StreamingTextRevealPreset;
agentTexMath: boolean;
};

function areMarkdownBlockViewPropsEqual(prev: MarkdownBlockViewProps, next: MarkdownBlockViewProps): boolean {
Expand All @@ -44,9 +48,11 @@ function areMarkdownBlockViewPropsEqual(prev: MarkdownBlockViewProps, next: Mark
&& prev.onOptionLongPress === next.onOptionLongPress
&& prev.onLinkPress === next.onLinkPress
&& prev.textStyle === next.textStyle
&& prev.profile === next.profile
&& prev.variant === next.variant
&& prev.streamingReveal === next.streamingReveal
&& prev.streamingRevealPreset === next.streamingRevealPreset;
&& prev.streamingRevealPreset === next.streamingRevealPreset
&& prev.agentTexMath === next.agentTexMath;
}

export const MarkdownBlockView = React.memo((props: MarkdownBlockViewProps) => {
Expand All @@ -71,7 +77,22 @@ export const MarkdownBlockView = React.memo((props: MarkdownBlockViewProps) => {
} else if (block.type === 'options') {
return <RenderOptionsBlock items={block.items} first={props.first} last={props.last} selectable={props.selectable} onOptionPress={props.onOptionPress} onOptionLongPress={props.onOptionLongPress} textStyle={props.textStyle} />;
} else if (block.type === 'table') {
return <RenderTableBlock headers={block.headers} rows={block.rows} alignments={block.alignments} first={props.first} last={props.last} selectable={props.selectable} textStyle={props.textStyle} />;
return (
<RenderTableBlock
headers={block.headers}
rows={block.rows}
alignments={block.alignments}
first={props.first}
last={props.last}
selectable={props.selectable}
onLinkPress={props.onLinkPress}
textStyle={props.textStyle}
profile={props.profile}
streamingReveal={props.streamingReveal}
streamingRevealPreset={props.streamingRevealPreset}
agentTexMath={props.agentTexMath}
/>
);
}
return null;
}, areMarkdownBlockViewPropsEqual);
Expand Down Expand Up @@ -300,7 +321,12 @@ function RenderTableBlock(props: {
first: boolean,
last: boolean,
selectable: boolean,
onLinkPress?: (url: string) => boolean | void,
textStyle?: StyleProp<TextStyle>,
profile: MarkdownRenderingProfile,
streamingReveal: boolean,
streamingRevealPreset?: StreamingTextRevealPreset,
agentTexMath: boolean,
}) {
const columnCount = props.headers.length;
const rowCount = props.rows.length;
Expand All @@ -326,7 +352,16 @@ function RenderTableBlock(props: {
>
{/* Header cell for this column */}
<View style={[style.tableCell, cellAlignmentStyle, style.tableHeaderCell, style.tableCellFirst]}>
<Text selectable={props.selectable} style={[style.tableHeaderText, textAlignmentStyle, props.textStyle]}>{header}</Text>
<RenderTableCellContent
markdown={header}
selectable={props.selectable}
onLinkPress={props.onLinkPress}
textStyle={[style.tableHeaderText, textAlignmentStyle, props.textStyle]}
profile={props.profile}
streamingReveal={props.streamingReveal}
streamingRevealPreset={props.streamingRevealPreset}
agentTexMath={props.agentTexMath}
/>
</View>
{/* Data cells for this column */}
{props.rows.map((row, rowIndex) => (
Expand All @@ -338,7 +373,16 @@ function RenderTableBlock(props: {
isLastRow(rowIndex) && style.tableCellLast
]}
>
<Text selectable={props.selectable} style={[style.tableCellText, textAlignmentStyle, props.textStyle]}>{row[colIndex] ?? ''}</Text>
<RenderTableCellContent
markdown={row[colIndex] ?? ''}
selectable={props.selectable}
onLinkPress={props.onLinkPress}
textStyle={[style.tableCellText, textAlignmentStyle, props.textStyle]}
profile={props.profile}
streamingReveal={props.streamingReveal}
streamingRevealPreset={props.streamingRevealPreset}
agentTexMath={props.agentTexMath}
/>
</View>
))}
</View>
Expand All @@ -360,6 +404,42 @@ function RenderTableBlock(props: {
);
}

function RenderTableCellContent(props: Readonly<{
markdown: string;
selectable: boolean;
onLinkPress?: (url: string) => boolean | void;
textStyle?: StyleProp<TextStyle>;
profile: MarkdownRenderingProfile;
streamingReveal: boolean;
streamingRevealPreset?: StreamingTextRevealPreset;
agentTexMath: boolean;
}>) {
if (!containsPotentialEnrichedMath(props.markdown, props.agentTexMath)) {
return <Text selectable={props.selectable} style={props.textStyle}>{props.markdown}</Text>;
}

return (
<EnrichedMarkdownTextAdapter
markdown={props.markdown}
profile={props.profile}
selectable={props.selectable}
onLinkPress={props.onLinkPress}
textStyle={props.textStyle}
streamingAnimated={props.streamingReveal}
streamingRevealPreset={props.streamingRevealPreset}
testID="markdown-table-cell-enriched"
suppressLeadingTopMargin
fillContainer={false}
agentTexMath={props.agentTexMath}
/>
);
}

function containsPotentialEnrichedMath(markdown: string, agentTexMath: boolean): boolean {
return markdown.includes('$')
|| (agentTexMath && (markdown.includes('\\(') || markdown.includes('\\[')));
}

function getTableCellAlignmentStyle(alignment: MarkdownTableAlignment) {
if (alignment === 'center') return style.tableCellAlignCenter;
if (alignment === 'right') return style.tableCellAlignRight;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('MarkdownView (enriched renderer)', () => {
expect(enrichedRuns[0]!.props.markdown).toBe(markdown);
expect(enrichedRuns[0]!.props.selectable).toBe(true);
expect(enrichedRuns[0]!.props.flavor).toBe('commonmark');
expect(enrichedRuns[0]!.props.md4cFlags).toEqual({ latexMath: true });
expect(enrichedRuns[0]!.props.md4cFlags).toEqual({ latexMath: true, texMathBackslashDelimiters: false });
expect(enrichedRuns[0]!.props.testID).toBeUndefined();
expect(enrichedRuns[0]!.props['data-testid']).toBe('markdown-enriched-run');
// The raw fallback is only hidden while the enriched runtime is still loading;
Expand All @@ -63,6 +63,19 @@ describe('MarkdownView (enriched renderer)', () => {
expect(enrichedRuns[0]!.props.streamingAnimation).toBeUndefined();
});

it('enables agent TeX parsing without rewriting the Markdown source', async () => {
const { MarkdownView } = await import('./MarkdownView');
const markdown = 'Coordinates: [\\(x\\), \\(y\\)]';

const screen = await renderScreen(
<MarkdownView markdown={markdown} selectable profile="transcript" agentTexMath />,
);

const enrichedRun = screen.findByType('EnrichedMarkdownText');
expect(enrichedRun.props.markdown).toBe(markdown);
expect(enrichedRun.props.md4cFlags).toEqual({ latexMath: true, texMathBackslashDelimiters: true });
});

it('keeps code fences as special blocks while grouping surrounding prose into enriched runs', async () => {
const { MarkdownView } = await import('./MarkdownView');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ describe('MarkdownView (tables)', () => {
expect(findTextNode('1').props.selectable).toBe(true);
}, 60_000);

it('renders math table cells through the enriched renderer without losing horizontal scrolling', async () => {
mockPlatform('android');
const { MarkdownView } = await import('./MarkdownView');

const markdown = [
'| Formula | Existing | Plain |',
'|---|---|---|',
'| \\(x_i\\) | $y_i$ | value |',
].join('\n');

const screen = await renderScreen(
<MarkdownView markdown={markdown} selectable profile="transcript" agentTexMath />,
);

expect(screen.findAllByType('GestureHandlerScrollView' as any)).toHaveLength(1);
expect(screen.findAllByType('EnrichedMarkdownText').map((node) => node.props.markdown)).toEqual([
'\\(x_i\\)',
'$y_i$',
]);
expect(screen.findAllByType('EnrichedMarkdownText').map((node) => node.props.md4cFlags)).toEqual([
{ latexMath: true, texMathBackslashDelimiters: true },
{ latexMath: true, texMathBackslashDelimiters: true },
]);
expect(screen.findAllByType('Text' as any).some((node) => node.props.children === 'value')).toBe(true);
}, 60_000);

it('applies GitHub table column alignment to header and body cells', async () => {
mockPlatform('web');
const { MarkdownView } = await import('./MarkdownView');
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/sources/components/markdown/MarkdownView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const MarkdownView = React.memo((props: {
onPressSourceRange?: (action: MarkdownSourceRangeAction) => void;
renderAfterSourceRange?: (action: MarkdownSourceRangeAction) => React.ReactNode;
highlightSourceRange?: MarkdownSourceRange | null;
agentTexMath?: boolean;
}) => {
const profile = normalizeMarkdownRenderingProfile({
profile: props.profile,
Expand All @@ -64,6 +65,7 @@ export const MarkdownView = React.memo((props: {
onPressSourceRange={props.onPressSourceRange}
renderAfterSourceRange={props.renderAfterSourceRange}
highlightSourceRange={props.highlightSourceRange}
agentTexMath={props.agentTexMath === true}
/>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ describe('enriched Markdown runtime readiness join', () => {
profile="transcript"
selectable
streamingAnimated={false}
agentTexMath={false}
/>
);
}
Expand Down Expand Up @@ -222,6 +223,7 @@ describe('enriched Markdown runtime readiness join', () => {
profile="transcript"
selectable
streamingAnimated={false}
agentTexMath={false}
/>,
);
await Promise.resolve();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,13 +745,6 @@ describe('EnrichedMarkdownText web streaming reveal', () => {
expect(parserSource).not.toContain("['string',");
});

it('clears parser state after web parse-call failures', () => {
const parserSource = readPatchedPackageFile('src/web/parseMarkdown.ts');

expect(parserSource).toContain('parseCache.clear()');
expect(parserSource).toContain('parserPromise = null');
});

it('uses themed paragraph fallback for web parse errors', () => {
const componentSource = readPatchedPackageFile('src/web/EnrichedMarkdownText.tsx');
const parseErrorFallbackStart = componentSource.indexOf('if (parseError)');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { Platform, type StyleProp, type TextStyle } from 'react-native';
import { EnrichedMarkdownText, type EnrichedMarkdownTextProps } from 'react-native-enriched-markdown';

import { ENRICHED_MARKDOWN_MD4C_FLAGS } from './enrichedMarkdownConstants';
import { resolveEnrichedMarkdownMd4cFlags } from './enrichedMarkdownConstants';
import { normalizeMarkdownLinkUrl, openMarkdownLinkUrl, sanitizeEnrichedMarkdownLinkTargets } from './enrichedMarkdownLinkHandling';
import { useEnrichedMarkdownRuntimeStatus } from './preloadEnrichedMarkdownRuntime';
import { resolveEnrichedMarkdownFlavor } from './resolveEnrichedMarkdownFlavor';
Expand Down Expand Up @@ -82,6 +82,8 @@ type EnrichedMarkdownTextAdapterProps = Readonly<{
streamingRevealPreset?: StreamingTextRevealPreset;
testID?: string;
suppressLeadingTopMargin?: boolean;
fillContainer?: boolean;
agentTexMath: boolean;
}>;

export const EnrichedMarkdownTextAdapter = React.memo((props: EnrichedMarkdownTextAdapterProps) => {
Expand All @@ -94,6 +96,7 @@ export const EnrichedMarkdownTextAdapter = React.memo((props: EnrichedMarkdownTe
() => sanitizeEnrichedMarkdownLinkTargets(props.markdown),
[props.markdown],
);
const md4cFlags = resolveEnrichedMarkdownMd4cFlags(props.agentTexMath);

const handleLinkPress = React.useCallback((event: { url: string }) => {
const normalizedUrl = normalizeMarkdownLinkUrl(event.url);
Expand Down Expand Up @@ -147,17 +150,20 @@ export const EnrichedMarkdownTextAdapter = React.memo((props: EnrichedMarkdownTe
}, [flavor, props.streamingAnimated, props.suppressLeadingTopMargin, props.testID, runtimeStatus]);

const containerStyle = React.useMemo(() => {
const baseContainerStyle = props.fillContainer === false
? { ...styleBundle.containerStyle, width: undefined }
: styleBundle.containerStyle;
if (Platform.OS !== 'web' || revealConfig == null) {
return styleBundle.containerStyle;
return baseContainerStyle;
}

return ({
...styleBundle.containerStyle,
...baseContainerStyle,
[ENRICHED_REVEAL_DURATION_VAR]: `${revealConfig.durationMs}ms`,
[ENRICHED_REVEAL_EASING_VAR]: revealConfig.easing,
[ENRICHED_REVEAL_TRANSLATE_Y_VAR]: `${revealConfig.translateYPx}px`,
} as unknown) as EnrichedMarkdownTextProps['containerStyle'];
}, [revealConfig, styleBundle.containerStyle]);
}, [props.fillContainer, revealConfig, styleBundle.containerStyle]);

return (
<EnrichedMarkdownText
Expand All @@ -166,7 +172,7 @@ export const EnrichedMarkdownTextAdapter = React.memo((props: EnrichedMarkdownTe
markdown={sanitizedMarkdown}
markdownStyle={styleBundle.markdownStyle}
containerStyle={containerStyle}
md4cFlags={ENRICHED_MARKDOWN_MD4C_FLAGS}
md4cFlags={md4cFlags}
onLinkPress={handleLinkPress}
selectable={props.selectable}
allowTrailingMargin={false}
Expand Down
Loading
Loading