diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1eb0b3be9a..08d8b1f3ad 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -102,6 +102,7 @@ "jotai": "2.20.2", "lowlight": "3.3.0", "lucide-react-native": "1.33.0", + "marked": "18.0.6", "nativewind": "5.0.0-preview.4", "posthog-react-native": "4.63.5", "react": "19.2.3", @@ -113,6 +114,7 @@ "react-native-gesture-handler": "2.32.0", "react-native-marked": "8.1.1", "react-native-reanimated": "4.5.1", + "react-native-render-html": "6.3.4", "react-native-safe-area-context": "5.7.0", "react-native-screens": "4.26.2", "react-native-svg": "15.15.4", diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx index 70711dec7d..f0b5657183 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -992,8 +992,9 @@ describe.each([true, false])('SessionDetailScreen header return with history=%s' ); const header = renderer.root.findByType(ScreenHeader); const title = header.findByProps({ accessibilityRole: 'header' }); - expect(propOf(title, 'numberOfLines')).toBe(1); + expect(propOf(title, 'numberOfLines')).toBe(2); expect(propOf(title, 'ellipsizeMode')).toBe('tail'); + expect(title.parent?.props.className).toContain('min-h-14'); const back = findByType(header, 'Pressable').find( node => propOf(node, 'accessibilityLabel') === 'Go back' ); diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index e6471fea16..df16560f1d 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -140,7 +140,7 @@ export default function SessionDetailScreen() { diff --git a/apps/mobile/src/components/agents/markdown-html-image.test.ts b/apps/mobile/src/components/agents/markdown-html-image.test.ts index 16f0bf6bd9..82e093e3bc 100644 --- a/apps/mobile/src/components/agents/markdown-html-image.test.ts +++ b/apps/mobile/src/components/agents/markdown-html-image.test.ts @@ -1,6 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { parseHtmlImages, stripToFixedPoint } from './markdown-html-image'; +import { + IMAGE_PREVIEW_MAX_ASPECT_RATIO, + IMAGE_PREVIEW_MIN_ASPECT_RATIO, +} from './tool-card-attachments'; + +import { + parseHtmlImages, + resolveHtmlImageAspectRatio, + stripToFixedPoint, +} from './markdown-html-image'; + +describe('resolveHtmlImageAspectRatio', () => { + it('returns the clamped preview ratio when both attributes parse as positive finite numbers', () => { + expect(resolveHtmlImageAspectRatio('1600', '900')).toBeCloseTo(1600 / 900); + expect(resolveHtmlImageAspectRatio('400', '2000')).toBe(IMAGE_PREVIEW_MIN_ASPECT_RATIO); + expect(resolveHtmlImageAspectRatio('4000', '500')).toBe(IMAGE_PREVIEW_MAX_ASPECT_RATIO); + }); + + it('returns undefined when an attribute is missing, unparsable, or not positive', () => { + expect(resolveHtmlImageAspectRatio(undefined, '900')).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('1600', undefined)).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('400px', '900')).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('1600', 'auto')).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('', '900')).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('0', '900')).toBeUndefined(); + expect(resolveHtmlImageAspectRatio('1600', '-1')).toBeUndefined(); + }); +}); describe('parseHtmlImages parser', () => { it('parses double-quoted attributes', () => { diff --git a/apps/mobile/src/components/agents/markdown-html-image.ts b/apps/mobile/src/components/agents/markdown-html-image.ts index 93631ebaa1..8af50aa263 100644 --- a/apps/mobile/src/components/agents/markdown-html-image.ts +++ b/apps/mobile/src/components/agents/markdown-html-image.ts @@ -63,6 +63,26 @@ export function stripToFixedPoint(value: string, re: RegExp): string { } } +/** + * width/height attributes → clamped preview aspect ratio, but only when both + * parse as positive finite numbers; otherwise `undefined` so the renderer can + * adopt the intrinsic ratio measured on load. + */ +export function resolveHtmlImageAspectRatio( + width: string | undefined, + height: string | undefined +): number | undefined { + if (width === undefined || height === undefined) { + return undefined; + } + const w = Number(width); + const h = Number(height); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return undefined; + } + return resolveImagePreviewAspectRatio(w, h); +} + function imgTagToImage(tag: string): HtmlImage | null { const srcRaw = attrValue(tag, ATTR_SRC); if (srcRaw === undefined) { @@ -76,18 +96,14 @@ function imgTagToImage(tag: string): HtmlImage | null { const altRaw = attrValue(tag, ATTR_ALT); const alt = altRaw !== undefined ? decodeEntities(altRaw) : ''; - let aspectRatio: number | undefined = undefined; - const widthRaw = attrValue(tag, ATTR_WIDTH); - const heightRaw = attrValue(tag, ATTR_HEIGHT); - if (widthRaw !== undefined && heightRaw !== undefined) { - const w = Number(widthRaw); - const h = Number(heightRaw); - if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { - aspectRatio = resolveImagePreviewAspectRatio(w, h); - } - } - - return { src, alt, aspectRatio }; + return { + src, + alt, + aspectRatio: resolveHtmlImageAspectRatio( + attrValue(tag, ATTR_WIDTH), + attrValue(tag, ATTR_HEIGHT) + ), + }; } /** diff --git a/apps/mobile/src/components/agents/markdown-html-sanitization.ts b/apps/mobile/src/components/agents/markdown-html-sanitization.ts new file mode 100644 index 0000000000..6d1bf557fb --- /dev/null +++ b/apps/mobile/src/components/agents/markdown-html-sanitization.ts @@ -0,0 +1,66 @@ +export const REMOVED_HTML_TAGS = [ + 'script', + 'style', + 'link', + 'iframe', + 'frame', + 'frameset', + 'object', + 'embed', + 'applet', + 'audio', + 'video', + 'source', + 'track', + 'form', + 'input', + 'button', + 'select', + 'option', + 'optgroup', + 'textarea', + 'label', + 'fieldset', + 'legend', + 'datalist', + 'output', + 'meter', + 'progress', + 'svg', + 'canvas', + 'base', + 'head', + 'meta', + 'title', + 'template', + 'noscript', +] as const; + +const REMOVED_TAG_NAMES = REMOVED_HTML_TAGS.join('|'); +const REMOVED_CONTAINER_RE = new RegExp( + `<(${REMOVED_TAG_NAMES})\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>`, + 'gi' +); +const REMOVED_TAG_RE = new RegExp(`<\\/?(?:${REMOVED_TAG_NAMES})\\b[^>]*>`, 'gi'); +const HTML_COMMENT_RE = //g; +// A container emptied by the removals above draws no ink either: the HTML +// engine renders `
` as an empty box. Stripping emptied containers +// (iterated to a fixpoint so nesting collapses outermost-last) keeps the +// predicate aligned with what the renderer actually paints. +const EMPTY_CONTAINER_RE = /<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>\s*<\/\1\s*>/g; + +/** True when the HTML renderer removes every non-whitespace character. */ +export function htmlSanitizesToEmpty(value: string): boolean { + let sanitized = value; + for (;;) { + const next = sanitized + .replace(HTML_COMMENT_RE, '') + .replace(REMOVED_CONTAINER_RE, '') + .replace(REMOVED_TAG_RE, '') + .replace(EMPTY_CONTAINER_RE, ''); + if (next === sanitized) { + return next.trim() === ''; + } + sanitized = next; + } +} diff --git a/apps/mobile/src/components/agents/markdown-html.mounted.test.tsx b/apps/mobile/src/components/agents/markdown-html.mounted.test.tsx new file mode 100644 index 0000000000..12e97faceb --- /dev/null +++ b/apps/mobile/src/components/agents/markdown-html.mounted.test.tsx @@ -0,0 +1,232 @@ +/* eslint-disable max-classes-per-file, typescript-eslint/no-deprecated, typescript-eslint/no-extraneous-class, typescript-eslint/no-unnecessary-condition, eslint/class-methods-use-this, eslint/no-empty-function, eslint-plugin-promise/prefer-await-to-callbacks, eslint-plugin-promise/prefer-await-to-then, typescript-eslint/promise-function-async -- the react-native host stub must mimic the module surface the real react-native-render-html engine consumes (classes with no-op methods, promise-returning Linking shims); 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 { MarkdownHtml, splitMarkdownHtml } from './markdown-html'; +import { type MarkdownPalette } from './markdown-palette'; + +// A host-element stub is the only way to run the real react-native-render-html +// engine in the DOM-free node test env. +const rnStub = vi.hoisted(() => { + const dim = { width: 375, height: 800, scale: 2, fontScale: 1 }; + const AnimatedValue = class { + setValue() {} + addListener() { + return '1'; + } + removeListener() {} + interpolate(opts: unknown) { + return opts; + } + }; + const stub = { + View: 'View', + Text: 'Text', + Image: 'Image', + Pressable: 'Pressable', + TouchableHighlight: 'TouchableHighlight', + TouchableNativeFeedback: { + selectable: true, + SelectableRipple: 'SelectableRipple', + }, + ActivityIndicator: 'ActivityIndicator', + Animated: { + View: 'Animated.View', + Text: 'Animated.Text', + Value: AnimatedValue, + timing: () => ({ + start: (cb?: unknown) => void (cb as { onFinish?: () => void })?.onFinish?.(), + }), + createAnimatedComponent: (C: unknown) => C, + }, + Dimensions: { get: () => dim, addEventListener: () => ({ remove: () => {} }) }, + I18nManager: { isRTL: false }, + PixelRatio: { + get: () => 2, + getFontScale: () => 1, + roundToNearestPixel: (n: number) => n, + getPixelSizeForLayoutSize: (n: number) => n * 2, + }, + Platform: { + OS: 'ios', + select: (values: { ios?: unknown; default?: unknown }) => values.ios ?? values.default, + }, + StyleSheet: { + create: (styles: Record) => styles, + flatten: (style: unknown) => style, + hairlineWidth: 1, + absoluteFill: { position: 'absolute' }, + absoluteFillObject: { position: 'absolute' }, + compose: (a: unknown, b: unknown) => [a, b], + }, + Linking: { openURL: () => Promise.resolve(), canOpenURL: () => Promise.resolve(true) }, + Alert: { alert: () => {} }, + Touchable: { Mixin: {} }, + findNodeHandle: (c: unknown) => c, + NativeModules: {}, + UIManager: { + getViewManagerConfig: () => null, + hasViewManagerConfig: () => false, + }, + useColorScheme: () => 'light', + useWindowDimensions: () => dim, + processColor: (c: unknown) => c, + }; + // Install the CJS require hook before any import in this file is evaluated + // (vi.hoisted factories run above hoisted ESM imports); react-native- + // render-html requires react-native outside the ESM graph. + const NodeModule = process.getBuiltinModule('module') as unknown as { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; + }; + const originalLoad = NodeModule._load.bind(NodeModule); + NodeModule._load = (request, parent, isMain) => + request === 'react-native' ? stub : originalLoad(request, parent, isMain); + return stub; +}); + +vi.mock('react-native', () => rnStub); +// The library's index pulls react-native-svg; its lexer export is literally +// marked.lexer (see dist/commonjs/index.js), so this mock is behavior-identical. +vi.mock('react-native-marked', async () => { + const { marked } = await import('marked'); + return { + MarkedLexer: (value: string) => marked.lexer(value, { gfm: true }), + useMarkdown: () => [], + Renderer: class {}, + }; +}); +vi.mock('./markdown-image', () => ({ MarkdownImage: 'MarkdownImage' })); +vi.mock('./markdown-link-confirm', () => ({ + confirmAndOpenMarkdownLink: vi.fn(), + formatLinkHost: (h: string) => h, +})); +vi.mock('./tool-card-attachments', () => ({ + resolveImagePreviewAspectRatio: () => 1, +})); + +const palette: MarkdownPalette = { + textColor: '#111111', + mutedTextColor: '#666666', + codeBackground: '#eeeeee', + borderColor: '#cccccc', + surfaceColor: '#ffffff', +}; + +function flattenStyle(style: unknown): Record[] { + if (style === null || style === undefined) { + return []; + } + if (Array.isArray(style)) { + return style.flatMap(entry => flattenStyle(entry)); + } + return [style as Record]; +} + +async function mountHtml(html: string): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create( + createElement(MarkdownHtml, { html, palette, selectable: true }) + ); + }); + if (!ref.current) { + throw new Error('renderer was not created'); + } + return ref.current; +} + +function styledTexts(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root + .findAll(node => typeof node.type === 'string' && (node.type as string) === 'Text') + .map(node => ({ + text: node.children.map(child => (typeof child === 'string' ? child : '')).join(''), + style: flattenStyle(node.props.style), + })); +} + +describe('splitMarkdownHtml nested HTML routing', () => { + it('routes a list whose items contain styled inline HTML to the HTML engine', () => { + const segments = splitMarkdownHtml( + '- Markdown: [example](https://example.com)\n- HTML: HTML link' + ); + + expect(segments).toHaveLength(1); + expect(segments[0]?.type).toBe('html'); + expect(segments[0]?.raw).toContain('HTML link'); + }); + + it('keeps a list without HTML and a list whose HTML is only unstyled tags on the Markdown path', () => { + expect(splitMarkdownHtml('- one\n- two')).toEqual([{ type: 'markdown', raw: '- one\n- two' }]); + expect(splitMarkdownHtml('-
plain
')).toEqual([ + { type: 'markdown', raw: '-
plain
' }, + ]); + }); + + it('keeps containers that hold fenced code or tables on the Markdown path', () => { + const codeList = + '- item HTML link\n\n ```js\n const a = 1;\n ```\n'; + expect(splitMarkdownHtml(codeList).every(segment => segment.type === 'markdown')).toBe(true); + const quoteWithTable = '> | a |\n> | --- |\n> | HTML link |'; + expect(splitMarkdownHtml(quoteWithTable).every(segment => segment.type === 'markdown')).toBe( + true + ); + }); +}); + +describe('MarkdownHtml nested-list styling (real HTML engine)', () => { + it('styles links, headings, and strong text inside a parsed list like their Markdown equivalents', async () => { + const renderer = await mountHtml( + '' + ); + const dump = styledTexts(renderer); + + const link = dump.find(entry => entry.text === 'HTML link'); + expect(link).toBeDefined(); + expect(link?.style.some(s => s.textDecorationLine === 'underline')).toBe(true); + const strong = dump.find(entry => entry.text === 'HTML strong'); + expect(strong).toBeDefined(); + expect(strong?.style.some(s => s.fontWeight === '700')).toBe(true); + // The tags themselves must not leak into the rendered text. + expect(dump.some(entry => entry.text.includes(' { + const renderer = await mountHtml('
    \n
  • HTML heading

  • \n
'); + const heading = styledTexts(renderer).find(entry => entry.text === 'HTML heading'); + + expect(heading).toBeDefined(); + expect(heading?.style.some(s => s.fontSize === 20 && s.fontWeight === '700')).toBe(true); + }); +}); + +describe('MarkdownHtml unsupported-image fallback', () => { + it('renders the alt text through the raw Text with the palette base style', async () => { + const renderer = await mountHtml('A photo'); + const fallback = styledTexts(renderer).find(entry => entry.text === 'A photo'); + + expect(fallback).toBeDefined(); + const style: Record = Object.assign({}, ...(fallback?.style ?? [])); + expect(style).toMatchObject({ color: '#111111', fontSize: 16, lineHeight: 24 }); + }); + + it('applies the RTL paragraph direction to the fallback text in RTL', async () => { + const isRtl = rnStub.I18nManager; + isRtl.isRTL = true; + try { + const renderer = await mountHtml('A photo'); + const fallback = styledTexts(renderer).find(entry => entry.text === 'A photo'); + + expect(fallback).toBeDefined(); + const style: Record = Object.assign({}, ...(fallback?.style ?? [])); + expect(style.writingDirection).toBe('rtl'); + } finally { + isRtl.isRTL = false; + } + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-html.tsx b/apps/mobile/src/components/agents/markdown-html.tsx new file mode 100644 index 0000000000..0fdf7918fc --- /dev/null +++ b/apps/mobile/src/components/agents/markdown-html.tsx @@ -0,0 +1,310 @@ +/* oxlint-disable max-lines -- cohesive HTML segmentation, sanitization, and image/link wiring share one renderer */ +import { useMemo } from 'react'; +import { marked, type Token } from 'marked'; +import { + type AccessibilityActionEvent, + type GestureResponderEvent, + Text, + useWindowDimensions, +} from 'react-native'; +import { MarkedLexer } from 'react-native-marked'; +import RenderHTML, { + type CustomBlockRenderer, + type CustomMixedRenderer, + type CustomTagRendererRecord, + type DomVisitorCallbacks, + type RenderersProps, + type TNode, +} from 'react-native-render-html'; + +import { withRtlWritingDirection } from '@/lib/rtl-text'; + +import { isSupportedScheme, resolveHtmlImageAspectRatio } from './markdown-html-image'; +import { REMOVED_HTML_TAGS } from './markdown-html-sanitization'; +import { MarkdownImage } from './markdown-image'; +import { confirmAndOpenMarkdownLink } from './markdown-link-confirm'; +import { getLinkAccessibilityActions, resolveLinkAccessibilityLabel } from './markdown-link'; +import { + getMarkdownHeadingStyles, + getMarkdownHtmlTagStyles, + type MarkdownPalette, +} from './markdown-palette'; +import { + type MarkdownLinkLongPressHandler, + type MarkdownLinkPressHandler, +} from './markdown-renderer'; + +const REMOVED_HTML_TAG_SET = new Set(REMOVED_HTML_TAGS); + +// Ignore only void tags here: the engine drops an ignored tag's whole subtree. +// The visitor below handles containers — clearing the contents of removed ones +// and hoisting the children of `picture` so its fallback `` still renders. +const IGNORED_HTML_TAGS = ['link', 'frame', 'embed', 'source', 'track', 'input', 'base', 'meta']; +const HTML_DOM_VISITORS: DomVisitorCallbacks = { + onElement(element) { + if (REMOVED_HTML_TAG_SET.has(element.name)) { + element.children.splice(0); + } else if (element.name === 'picture' && element.parent !== null) { + // Dropping the `` wrapper must not drop its fallback ``: + // replace the wrapper with its children (`` candidates never + // reach the tree; removed children are already cleared above). + const index = element.parent.children.indexOf(element); + if (index !== -1) { + element.parent.children.splice(index, 1, ...element.children); + } + } + }, +}; + +type MarkdownHtmlSegment = { + type: 'html' | 'markdown'; + raw: string; +}; + +function pushSegment(segments: MarkdownHtmlSegment[], segment: MarkdownHtmlSegment) { + if (segment.raw.length === 0) { + return; + } + const previous = segments.at(-1); + if (previous?.type === segment.type) { + previous.raw += segment.raw; + } else { + segments.push(segment); + } +} + +function hasDirectHtml(token: Token): boolean { + if (token.type !== 'paragraph' && token.type !== 'heading') { + return false; + } + return (token.tokens ?? []).some(inlineToken => inlineToken.type === 'html'); +} + +// react-native-marked renders inline HTML tokens through `MarkdownRenderer.html`, +// which shows them as plain text: a link, heading, or emphasis tag nested inside +// a list item or blockquote loses the styling its Markdown equivalent keeps. +// Those tags are the ones the HTML engine styles; containers holding only +// unstyled tags (div, span, …) stay on the Markdown path by design. +const STYLED_HTML_TAGS = new Set([ + 'a', + 'b', + 'strong', + 'em', + 'i', + 'u', + 's', + 'del', + 'ins', + 'mark', + 'small', + 'sub', + 'sup', + 'code', + 'kbd', + 'samp', + 'var', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'br', + 'hr', +]); + +// Containers whose nested HTML the Markdown lexer cannot style. Code and table +// descendants are excluded from routing so fenced code keeps the CodeBlock and +// tables keep the MarkdownTable chip. +const NESTED_HTML_CONTAINERS = new Set(['list', 'blockquote']); + +function tokenChildren(token: Token): Token[] { + const container = token as { tokens?: Token[]; items?: Token[] }; + return [...(container.tokens ?? []), ...(container.items ?? [])]; +} + +function htmlRawHasStyledTag(raw: string): boolean { + for (const match of raw.matchAll(/<\s*\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/g)) { + const tagName = match[1]; + if (tagName !== undefined && STYLED_HTML_TAGS.has(tagName.toLowerCase())) { + return true; + } + } + return false; +} + +function containsStyledHtml(token: Token): boolean { + if (token.type === 'html') { + return htmlRawHasStyledTag(token.raw); + } + return tokenChildren(token).some(child => containsStyledHtml(child)); +} + +function containsRichBlock(token: Token): boolean { + if (token.type === 'code' || token.type === 'table') { + return true; + } + return tokenChildren(token).some(child => containsRichBlock(child)); +} + +function routesNestedHtml(token: Token): boolean { + return ( + NESTED_HTML_CONTAINERS.has(token.type) && containsStyledHtml(token) && !containsRichBlock(token) + ); +} + +export function splitMarkdownHtml(value: string): MarkdownHtmlSegment[] { + // eslint-disable-next-line new-cap -- react-native-marked exports the lexer function with this name + const tokens = MarkedLexer(value, { gfm: true }); + const segments: MarkdownHtmlSegment[] = []; + for (const token of tokens) { + if (token.type === 'html') { + pushSegment(segments, { type: 'html', raw: token.raw }); + } else if (hasDirectHtml(token) || routesNestedHtml(token)) { + pushSegment(segments, { + type: 'html', + raw: marked.parse(token.raw, { async: false, gfm: true }), + }); + } else { + pushSegment(segments, { type: 'markdown', raw: token.raw }); + } + } + return segments.some(segment => segment.type === 'html') + ? segments + : [{ type: 'markdown', raw: value }]; +} + +function parentAnchor(tnode: TNode): TNode | null { + let current = tnode.parent; + while (current !== null) { + if (current.tagName === 'a') { + return current; + } + current = current.parent; + } + return null; +} + +type MarkdownHtmlProps = { + html: string; + palette: MarkdownPalette; + selectable: boolean; + onLongPressLink?: MarkdownLinkLongPressHandler; + onPressLink?: MarkdownLinkPressHandler; +}; + +export function MarkdownHtml({ + html, + palette, + selectable, + onLongPressLink, + onPressLink, +}: Readonly) { + const { width } = useWindowDimensions(); + const source = useMemo(() => ({ html }), [html]); + const baseStyle = useMemo( + () => ({ color: palette.textColor, fontSize: 16, lineHeight: 24 }), + [palette] + ); + const tagsStyles = useMemo( + () => ({ ...getMarkdownHeadingStyles(palette), ...getMarkdownHtmlTagStyles(palette) }), + [palette] + ); + const renderersProps = useMemo>( + () => ({ + a: { + onPress: (_event, href, attributes) => { + if (!onPressLink?.(href)) { + confirmAndOpenMarkdownLink(href, { label: attributes.title }); + } + }, + }, + }), + [onPressLink] + ); + const renderers = useMemo(() => { + const showLinkActions = (href: string, label?: string, event?: GestureResponderEvent) => { + if (onLongPressLink) { + onLongPressLink(href, event); + } else { + confirmAndOpenMarkdownLink(href, { label }); + } + }; + const HtmlAnchor: CustomMixedRenderer = ({ InternalRenderer, ...props }) => { + const href = props.tnode.attributes.href ?? ''; + const label = props.tnode.attributes.title; + return ( + { + if (event.nativeEvent.actionName === 'showLinkActions') { + onLongPressLink?.(href); + } + }, + onLongPress: (event: GestureResponderEvent) => { + showLinkActions(href, label, event); + }, + }} + /> + ); + }; + const HtmlImage: CustomBlockRenderer = ({ tnode }) => { + const src = tnode.attributes.src ?? ''; + if (!isSupportedScheme(src)) { + return ( + + {tnode.attributes.alt ?? ''} + + ); + } + const anchor = parentAnchor(tnode); + const href = anchor?.attributes.href; + const linkLabel = href + ? resolveLinkAccessibilityLabel(tnode.attributes.alt ?? '', href, anchor.attributes.title) + : undefined; + return ( + { + if (!onPressLink?.(href)) { + confirmAndOpenMarkdownLink(href, { label: linkLabel }); + } + } + : undefined + } + onShowLinkActions={ + href + ? () => { + showLinkActions(href, anchor.attributes.title); + } + : undefined + } + /> + ); + }; + return { a: HtmlAnchor, img: HtmlImage }; + }, [baseStyle, onLongPressLink, onPressLink, selectable]); + + return ( + + ); +} diff --git a/apps/mobile/src/components/agents/markdown-image-confirm.ts b/apps/mobile/src/components/agents/markdown-image-confirm.ts index faa3874518..592ae00944 100644 --- a/apps/mobile/src/components/agents/markdown-image-confirm.ts +++ b/apps/mobile/src/components/agents/markdown-image-confirm.ts @@ -5,15 +5,34 @@ * sign-out so one account's confirmations never auto-load for another. */ const confirmedUris = new Set(); +const listeners = new Set<() => void>(); + +export function subscribeMarkdownImageConfirmMemory(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} export function isMarkdownImageConfirmed(uri: string): boolean { return confirmedUris.has(uri); } export function confirmMarkdownImage(uri: string): void { - confirmedUris.add(uri); + if (!confirmedUris.has(uri)) { + confirmedUris.add(uri); + for (const listener of listeners) { + listener(); + } + } } export function clearMarkdownImageConfirmMemory(): void { + if (confirmedUris.size === 0) { + return; + } confirmedUris.clear(); + for (const listener of listeners) { + listener(); + } } diff --git a/apps/mobile/src/components/agents/markdown-image.test.ts b/apps/mobile/src/components/agents/markdown-image.test.ts index 0a41cc888a..98883503b2 100644 --- a/apps/mobile/src/components/agents/markdown-image.test.ts +++ b/apps/mobile/src/components/agents/markdown-image.test.ts @@ -9,13 +9,17 @@ import { clearMarkdownImageConfirmMemory, confirmMarkdownImage } from './markdow import { MarkdownImage } from './markdown-image'; vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' })); -vi.mock('@/components/ui/icons', () => ({ AlertCircle: 'AlertCircle', Download: 'Download' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + Download: 'Download', + RotateCcw: 'RotateCcw', +})); vi.mock('@/components/image-viewer-modal', () => ({ ImageViewerModal: 'ImageViewerModal' })); vi.mock('@/components/ui/image', () => ({ Image: 'Image' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ mutedForeground: '#666666' }), + useThemeColors: () => ({ foreground: '#111111', mutedForeground: '#666666' }), })); beforeEach(() => { @@ -39,6 +43,12 @@ function texts(root: TestRenderer.ReactTestInstance): string[] { }); } +function slotCount(root: TestRenderer.ReactTestInstance, aspectRatio: number): number { + return root.findAll( + node => (node.props.style as { aspectRatio?: number } | undefined)?.aspectRatio === aspectRatio + ).length; +} + function loadLabel(uri: string): string { return `Load ${new URL(uri).hostname.toLowerCase()}`; } @@ -58,7 +68,12 @@ function findLoadButtons( async function mount( uri: string, alt = '', - onShowLinkActions?: () => void + options: { + accessibilityLabel?: string; + aspectRatio?: number; + onPress?: () => void; + onShowLinkActions?: () => void; + } = {} ): Promise { const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined, @@ -66,7 +81,7 @@ async function mount( await act(async () => { await Promise.resolve(); rendererRef.current = TestRenderer.create( - createElement(MarkdownImage, { uri, alt, onShowLinkActions }) + createElement(MarkdownImage, { uri, alt, ...options }) ); }); const renderer = rendererRef.current; @@ -87,6 +102,7 @@ describe('MarkdownImage inert-until-load', () => { it('stays inert for HTTPS until Load, then mounts the Image', async () => { const renderer = await mount('https://example.com/a.png'); expect(ofType(renderer.root, 'Image')).toHaveLength(0); + expect(texts(renderer.root)).toContain('Load'); const loadButtons = findLoadButtons(renderer.root, 'https://example.com/a.png'); expect(loadButtons).toHaveLength(1); @@ -99,6 +115,7 @@ describe('MarkdownImage inert-until-load', () => { (loadButton.props.onPress as () => void)(); }); expect(ofType(renderer.root, 'Image')).toHaveLength(1); + expect(ofType(renderer.root, 'Image')[0]?.props.recyclingKey).toBe('https://example.com/a.png'); await unmount(renderer); }); @@ -122,17 +139,53 @@ describe('MarkdownImage inert-until-load', () => { await unmount(second); }); + it('loads every mounted slot for a confirmed HTTPS URI', async () => { + const uri = 'https://example.com/a.png'; + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create( + createElement( + 'View', + null, + createElement(MarkdownImage, { uri, alt: 'first' }), + createElement(MarkdownImage, { uri, alt: 'second' }) + ) + ); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + + const load = findLoadButtons(renderer.root, uri)[0]; + if (!load) { + throw new Error('load button not found'); + } + await act(async () => { + await Promise.resolve(); + (load.props.onPress as () => void)(); + }); + + expect(ofType(renderer.root, 'Image')).toHaveLength(2); + await unmount(renderer); + }); + it('renders http and data URIs as static chips without fetching', async () => { const httpRenderer = await mount('http://insecure.com/a.png'); expect(ofType(httpRenderer.root, 'Image')).toHaveLength(0); expect(ofType(httpRenderer.root, 'Pressable')).toHaveLength(0); expect(texts(httpRenderer.root)).toContain('insecure.com · HTTPS images only'); + expect(slotCount(httpRenderer.root, 4 / 3)).toBe(0); await unmount(httpRenderer); const dataRenderer = await mount('data:image/png;base64,abc'); expect(ofType(dataRenderer.root, 'Image')).toHaveLength(0); expect(ofType(dataRenderer.root, 'Pressable')).toHaveLength(0); expect(texts(dataRenderer.root)).toContain('HTTPS images only'); + expect(slotCount(dataRenderer.root, 4 / 3)).toBe(0); await unmount(dataRenderer); }); @@ -150,7 +203,8 @@ describe('MarkdownImage inert-until-load', () => { (image.props.onError as () => void)(); }); expect(ofType(renderer.root, 'Image')).toHaveLength(0); - expect(texts(renderer.root)).toContain('Image unavailable shot'); + expect(texts(renderer.root)).toContain('Image unavailable\nshot'); + expect(texts(renderer.root)).toContain('Retry'); const retryButtons = renderer.root.findAll( node => @@ -172,6 +226,114 @@ describe('MarkdownImage inert-until-load', () => { await unmount(renderer); }); + it.each([ + { dimensions: { width: 800, height: 1000 }, expectedRatio: 0.8, shape: 'portrait' }, + { dimensions: { width: 2500, height: 1000 }, expectedRatio: 2.5, shape: 'panorama' }, + ])( + 'uses the intrinsic ratio for a plain Markdown $shape image', + async ({ dimensions, expectedRatio }) => { + const uri = `https://example.com/${dimensions.width}x${dimensions.height}.png`; + confirmMarkdownImage(uri); + const renderer = await mount(uri, 'shot'); + const image = ofType(renderer.root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + + await act(async () => { + await Promise.resolve(); + (image.props.onLoad as (event: unknown) => void)({ source: dimensions }); + }); + + expect(slotCount(renderer.root, expectedRatio)).toBe(1); + await unmount(renderer); + } + ); + + it('keeps the measured ratio through failure, retry, and refresh', async () => { + const uri = 'https://example.com/a.png'; + let renderer = await mount(uri, 'shot'); + + expect(slotCount(renderer.root, 4 / 3)).toBe(1); + const loadButton = findLoadButtons(renderer.root, 'https://example.com/a.png')[0]; + if (!loadButton) { + throw new Error('load button not found'); + } + await act(async () => { + await Promise.resolve(); + (loadButton.props.onPress as () => void)(); + }); + expect(slotCount(renderer.root, 4 / 3)).toBe(1); + expect(ofType(renderer.root, 'Skeleton')).toHaveLength(1); + + const image = ofType(renderer.root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onLoad as (event: unknown) => void)({ + source: { width: 100, height: 400 }, + }); + }); + expect(slotCount(renderer.root, 0.75)).toBe(1); + expect(ofType(renderer.root, 'Skeleton')).toHaveLength(0); + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + expect(slotCount(renderer.root, 0.75)).toBe(1); + + const retry = renderer.root.find( + node => node.props.accessibilityLabel === 'Image unavailable, retry loading' + ); + await act(async () => { + await Promise.resolve(); + (retry.props.onPress as () => void)(); + }); + expect(slotCount(renderer.root, 0.75)).toBe(1); + expect(ofType(renderer.root, 'Skeleton')).toHaveLength(1); + + const retryImage = ofType(renderer.root, 'Image')[0]; + if (!retryImage) { + throw new Error('image not found after retry'); + } + await act(async () => { + await Promise.resolve(); + (retryImage.props.onLoad as (event: unknown) => void)({ + source: { width: 100, height: 400 }, + }); + renderer.update(createElement(MarkdownImage, { uri, alt: 'shot' })); + }); + expect(slotCount(renderer.root, 0.75)).toBe(1); + + await unmount(renderer); + renderer = await mount(uri, 'shot'); + expect(slotCount(renderer.root, 4 / 3)).toBe(1); + await unmount(renderer); + }); + + it('keeps an explicit HTML image ratio', async () => { + const uri = 'https://example.com/html.png'; + confirmMarkdownImage(uri); + const renderer = await mount(uri, 'shot', { aspectRatio: 2 }); + const image = ofType(renderer.root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + + await act(async () => { + await Promise.resolve(); + (image.props.onLoad as (event: unknown) => void)({ + source: { width: 100, height: 400 }, + }); + }); + + expect(slotCount(renderer.root, 2)).toBe(1); + expect(slotCount(renderer.root, 0.75)).toBe(0); + await unmount(renderer); + }); + it('renders alt text for an empty src', async () => { const renderer = await mount('', 'photo'); expect(ofType(renderer.root, 'Image')).toHaveLength(0); @@ -228,14 +390,14 @@ describe('MarkdownImage inert-until-load', () => { (image.props.onError as () => void)(); }); // Old URI shows the retry chip. - expect(texts(renderer.root)).toContain('Image unavailable shot'); + expect(texts(renderer.root)).toContain('Image unavailable\nshot'); // Recycle to a new, unconfirmed URI: it must show Load, never the old chip. await act(async () => { await Promise.resolve(); renderer.update(createElement(MarkdownImage, { uri: 'https://example.com/b.png', alt: '' })); }); - expect(texts(renderer.root)).not.toContain('Image unavailable shot'); + expect(texts(renderer.root)).not.toContain('Image unavailable\nshot'); expect(ofType(renderer.root, 'Image')).toHaveLength(0); expect(findLoadButtons(renderer.root, 'https://example.com/b.png')).toHaveLength(1); @@ -268,7 +430,9 @@ describe('MarkdownImage inert-until-load', () => { it('exposes showLinkActions on the Load chip and routes it to the callback', async () => { const onShow = vi.fn<() => void>(); - const renderer = await mount('https://example.com/a.png', '', onShow); + const renderer = await mount('https://example.com/a.png', '', { + onShowLinkActions: onShow, + }); const load = findLoadButtons(renderer.root, 'https://example.com/a.png')[0]; if (!load) { throw new Error('load button not found'); @@ -300,7 +464,9 @@ describe('MarkdownImage inert-until-load', () => { it('exposes showLinkActions on the blocked chip when a callback is supplied', async () => { const onShow = vi.fn<() => void>(); - const renderer = await mount('http://insecure.com/a.png', '', onShow); + const renderer = await mount('http://insecure.com/a.png', '', { + onShowLinkActions: onShow, + }); const chip = renderer.root.find( node => typeof node.type === 'string' && @@ -324,7 +490,9 @@ describe('MarkdownImage inert-until-load', () => { it('exposes showLinkActions on the retry chip when a callback is supplied', async () => { confirmMarkdownImage('https://example.com/a.png'); const onShow = vi.fn<() => void>(); - const renderer = await mount('https://example.com/a.png', 'shot', onShow); + const renderer = await mount('https://example.com/a.png', 'shot', { + onShowLinkActions: onShow, + }); const image = ofType(renderer.root, 'Image')[0]; if (!image) { @@ -363,7 +531,9 @@ describe('MarkdownImage inert-until-load', () => { it('keeps the viewer as the default action after load and carries showLinkActions', async () => { confirmMarkdownImage('https://example.com/a.png'); const onShow = vi.fn<() => void>(); - const renderer = await mount('https://example.com/a.png', 'shot', onShow); + const renderer = await mount('https://example.com/a.png', 'shot', { + onShowLinkActions: onShow, + }); const imageButton = renderer.root.find( node => typeof node.type === 'string' && @@ -383,6 +553,31 @@ describe('MarkdownImage inert-until-load', () => { await unmount(renderer); }); + it('keeps the image description when a confirmed linked image remounts', async () => { + confirmMarkdownImage('https://example.com/a.png'); + const onPress = vi.fn<() => void>(); + const renderer = await mount('https://example.com/a.png', 'shot', { + accessibilityLabel: 'Example', + onPress, + }); + const imageLink = renderer.root.find( + node => + typeof node.type === 'string' && + (node.type as string) === 'Pressable' && + node.props.accessibilityLabel === 'View image shot and Example' + ); + expect(imageLink.props.accessibilityRole).toBe('link'); + + await act(async () => { + await Promise.resolve(); + (imageLink.props.onPress as () => void)(); + }); + expect(onPress).toHaveBeenCalledTimes(1); + expect(ofType(renderer.root, 'ImageViewerModal')).toHaveLength(0); + + await unmount(renderer); + }); + it('Load control is at least 44pt and announces host plus action', async () => { const renderer = await mount('https://example.com/a.png'); const load = findLoadButtons(renderer.root, 'https://example.com/a.png')[0]; diff --git a/apps/mobile/src/components/agents/markdown-image.tsx b/apps/mobile/src/components/agents/markdown-image.tsx index 1767ebd3d2..fab66d45c7 100644 --- a/apps/mobile/src/components/agents/markdown-image.tsx +++ b/apps/mobile/src/components/agents/markdown-image.tsx @@ -1,15 +1,20 @@ -import { useReducer, useState } from 'react'; +import { type ReactElement, useState, useSyncExternalStore } from 'react'; import { Pressable, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { ImageViewerModal } from '@/components/image-viewer-modal'; -import { AlertCircle, Download } from '@/components/ui/icons'; +import { AlertCircle, Download, RotateCcw } from '@/components/ui/icons'; import { Image } from '@/components/ui/image'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { formatList } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { confirmMarkdownImage, isMarkdownImageConfirmed } from './markdown-image-confirm'; +import { + confirmMarkdownImage, + isMarkdownImageConfirmed, + subscribeMarkdownImageConfirmMemory, +} from './markdown-image-confirm'; import { resolveMarkdownImageSrc } from './markdown-image-src'; import { getLinkAccessibilityActions } from './markdown-link'; import { @@ -41,6 +46,21 @@ function imageHostDisplay(uri: string): string | null { } } +function FixedImageSlot({ + aspectRatio, + children, +}: Readonly<{ aspectRatio: number | undefined; children: ReactElement }>): ReactElement { + return ( + + {children} + + ); +} + /** Static chip for http and data URIs: HTTPS-only copy, host name for http. */ function BlockedImageChip({ kind, @@ -83,7 +103,7 @@ function UnconfirmedImageChip({ const { t } = useTranslation(); const host = imageHostDisplay(uri); return ( - + {host ?? t('agentChat.markdownImage.httpsOnly')} @@ -101,9 +121,12 @@ function UnconfirmedImageChip({ onShowLinkActions?.(); } }} - className="min-h-11 min-w-11 shrink-0 items-center justify-center active:opacity-70" + className="min-h-11 min-w-11 shrink-0 flex-row items-center justify-center gap-1 px-2 active:opacity-70" > + + {t('agentChat.markdownImage.load')} + ); @@ -113,19 +136,20 @@ export function MarkdownImage({ uri, alt, aspectRatio, + accessibilityLabel, + onPress, onShowLinkActions, }: Readonly<{ uri: string; alt: string; aspectRatio?: number; + accessibilityLabel?: string; + onPress?: () => void; onShowLinkActions?: () => void; }>) { const colors = useThemeColors(); - const { t } = useTranslation(); - // Confirmation follows the current uri on every render, so a recycled - // instance never keeps a previous uri's consent. forceRender only re-runs - // the render after confirmMarkdownImage mutates the module Set. - const [, forceRender] = useReducer((n: number) => n + 1, 0); + const { i18n, t } = useTranslation(); + const [loaded, setLoaded] = useState(false); const [measuredAspectRatio, setMeasuredAspectRatio] = useState(undefined); const [failed, setFailed] = useState(false); const [viewerVisible, setViewerVisible] = useState(false); @@ -140,14 +164,20 @@ export function MarkdownImage({ setFailed(false); setViewerVisible(false); setAttempt(0); + setLoaded(false); setMeasuredAspectRatio(undefined); } const filename = alt || (uri.startsWith('http') ? getFilename(uri.split('?')[0] ?? '') : '') || 'image'; + const imageAccessibilityLabel = alt + ? t('agentChat.filePart.viewImageWithAlt', { alt }) + : t('agentChat.filePart.viewImage'); const kind = classifyUri(uri); - const confirmed = isMarkdownImageConfirmed(uri); + const confirmed = useSyncExternalStore(subscribeMarkdownImageConfirmMemory, () => + isMarkdownImageConfirmed(uri) + ); if (kind === 'http' || kind === 'data') { return ; @@ -155,14 +185,15 @@ export function MarkdownImage({ if (kind === 'https' && !confirmed) { return ( - { - confirmMarkdownImage(uri); - forceRender(); - }} - /> + + { + confirmMarkdownImage(uri); + }} + /> + ); } @@ -174,77 +205,82 @@ export function MarkdownImage({ if (failed) { return ( - { - setFailed(false); - setMeasuredAspectRatio(undefined); - setAttempt(prev => prev + 1); - }} - onLongPress={onShowLinkActions} - className="flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" - accessibilityRole="button" - accessibilityLabel={t('agentChat.filePart.imageUnavailableRetry')} - accessibilityActions={onShowLinkActions ? getLinkAccessibilityActions(true) : undefined} - onAccessibilityAction={event => { - if (event.nativeEvent.actionName === 'showLinkActions') { - onShowLinkActions?.(); - } - }} - > - - - {alt - ? t('agentChat.filePart.imageUnavailableWithAlt', { alt }) - : t('common.imageUnavailable')} - - + + { + setFailed(false); + setLoaded(false); + setAttempt(prev => prev + 1); + }} + onLongPress={onShowLinkActions} + className="h-full flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" + accessibilityRole="button" + accessibilityLabel={t('agentChat.filePart.imageUnavailableRetry')} + accessibilityActions={onShowLinkActions ? getLinkAccessibilityActions(true) : undefined} + onAccessibilityAction={event => { + if (event.nativeEvent.actionName === 'showLinkActions') { + onShowLinkActions?.(); + } + }} + > + + + {alt ? `${t('common.imageUnavailable')}\n${alt}` : t('common.imageUnavailable')} + + + {t('common.retry')} + + ); } return ( <> - { - setViewerVisible(true); - }} - onLongPress={onShowLinkActions} - className="my-1 w-full overflow-hidden rounded-md bg-neutral-100 active:opacity-80 dark:bg-neutral-900" - accessibilityRole="button" - accessibilityLabel={ - alt - ? t('agentChat.filePart.viewImageWithAlt', { alt }) - : t('agentChat.filePart.viewImage') - } - accessibilityActions={onShowLinkActions ? getLinkAccessibilityActions(true) : undefined} - onAccessibilityAction={event => { - if (event.nativeEvent.actionName === 'showLinkActions') { - onShowLinkActions?.(); + + { + setViewerVisible(true); + }) } - }} - // eslint-disable-next-line react-native/no-inline-styles -- measured aspect ratio cannot be a Tailwind class - style={{ - aspectRatio: measuredAspectRatio ?? aspectRatio ?? IMAGE_PREVIEW_FALLBACK_ASPECT_RATIO, - }} - > - {measuredAspectRatio === undefined ? : null} - { - setMeasuredAspectRatio( - resolveImagePreviewAspectRatio(event.source.width, event.source.height) - ); - }} - onError={() => { - setFailed(true); + onLongPress={onShowLinkActions} + className="h-full w-full active:opacity-80" + accessibilityRole={onPress ? 'link' : 'button'} + accessibilityLabel={ + accessibilityLabel && accessibilityLabel !== alt + ? formatList([imageAccessibilityLabel, accessibilityLabel], i18n.language) + : imageAccessibilityLabel + } + accessibilityActions={onShowLinkActions ? getLinkAccessibilityActions(true) : undefined} + onAccessibilityAction={event => { + if (event.nativeEvent.actionName === 'showLinkActions') { + onShowLinkActions?.(); + } }} - /> - + > + {!loaded ? : null} + { + setMeasuredAspectRatio( + resolveImagePreviewAspectRatio(event.source.width, event.source.height) + ); + setLoaded(true); + }} + onError={() => { + setFailed(true); + }} + /> + + {viewerVisible && ( ({ + View: 'View', + Text: 'Text', + Image: 'Image', + TouchableHighlight: 'TouchableHighlight', + TouchableNativeFeedback: 'TouchableNativeFeedback', + Dimensions: { get: () => ({ width: 320, height: 640, scale: 2, fontScale: 1 }) }, + I18nManager: { isRTL: false }, + PixelRatio: { get: () => 2 }, + Platform: { OS: 'ios', select: (values: { ios?: unknown; default?: unknown }) => values.ios }, + StyleSheet: { + create: (styles: Record) => styles, + flatten: (style: unknown) => style, + hairlineWidth: 1, + }, + useColorScheme: () => 'light', + useWindowDimensions: () => ({ width: 320, height: 640, scale: 2, fontScale: 1 }), +})); +type CjsLoad = (request: string, parent: NodeJS.Module | null, isMain: boolean) => unknown; +const ModuleWithLoad = Module as unknown as { _load: CjsLoad }; +const originalLoad = ModuleWithLoad._load.bind(ModuleWithLoad); +ModuleWithLoad._load = (request, parent, isMain) => + request === 'react-native' ? rnStub : originalLoad(request, parent, isMain); + +vi.mock('react-native', () => rnStub); +vi.mock('react-native-marked', async () => { + const [{ marked }, React] = await Promise.all([import('marked'), import('react')]); + return { + MarkedLexer: vi.fn((value: string) => marked.lexer(value, { gfm: true })), + useMarkdown: vi.fn((value: string) => [ + React.createElement('MarkdownOutput', { key: 'output', value }), + ]), + }; +}); +vi.mock('react-native-render-html', () => ({ default: 'RenderHTML' })); +vi.mock('@/lib/hooks/use-theme-colors', () => { + // One stable object per suite, like the real hook's module-level constants: + // a fresh object per call would recreate the palette (and the segment + // renderer) on every render and mask remount regressions. + const colors = { + foreground: '#111111', + mutedForeground: '#666666', + muted: '#eeeeee', + border: '#cccccc', + card: '#ffffff', + primaryForeground: '#ffffff', + primary: '#111111', + accentSoftForeground: '#111111', + accentSoft: '#eeeeee', + }; + return { useThemeColors: () => colors }; +}); +vi.mock('./markdown-renderer', () => ({ + MarkdownRenderer: vi.fn(), +})); +vi.mock('./markdown-table', () => ({ MarkdownTable: 'MarkdownTable' })); +vi.mock('./markdown-image', () => ({ MarkdownImage: 'MarkdownImage' })); +vi.mock('./markdown-link', () => ({ + getLinkAccessibilityActions: (enabled: boolean) => + enabled ? [{ name: 'showLinkActions', label: 'Show link actions' }] : undefined, + resolveLinkAccessibilityLabel: (_children: unknown, _href: string, title?: string) => + title ?? 'link', +})); +vi.mock('./markdown-link-confirm', () => ({ + confirmAndOpenMarkdownLink: vi.fn(), +})); + +type RenderHtmlHostProps = { + baseStyle: Record; + defaultTextProps: { selectable: boolean }; + domVisitors: DomVisitorCallbacks; + enableCSSInlineProcessing: boolean; + ignoredDomTags: string[]; + renderers: CustomTagRendererRecord; + renderersProps: Partial; + source: { html: string }; + tagsStyles: Record>; +}; +const RenderHTMLType = 'RenderHTML' as unknown as ComponentType; +const AnchorType = 'Anchor' as unknown as ComponentType; +const MarkdownImageType = 'MarkdownImage' as unknown as ComponentType; +const MarkdownTableType = 'MarkdownTable' as unknown as ComponentType; +const TextType = 'Text' as unknown as ComponentType; +const ViewType = 'View' as unknown as ComponentType; + +async function mount(element: ReactElement): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + ref.current = TestRenderer.create(element); + }); + if (!ref.current) { + throw new Error('renderer was not created'); + } + return ref.current; +} + +function htmlProps(renderer: TestRenderer.ReactTestRenderer): RenderHtmlHostProps { + return renderer.root.findByType(RenderHTMLType).props as RenderHtmlHostProps; +} + +function visibleText(tnode: TNode): string { + if (tnode.type === 'text') { + return tnode.data; + } + return tnode.children.map(visibleText).join(''); +} + +async function renderCustom( + Renderer: CustomTagRendererRecord[string], + tnode: Record, + extra: Record = {} +): Promise { + const props = { tnode, ...extra }; + const TestComponent = Renderer as unknown as ComponentType>; + const renderer = await mount(createElement(TestComponent, props)); + return renderer; +} + +function requiredRenderer( + renderers: CustomTagRendererRecord, + tag: string +): CustomTagRendererRecord[string] { + const Renderer = renderers[tag]; + if (!Renderer) { + throw new Error(`${tag} renderer was not created`); + } + return Renderer; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MarkdownText HTML routing', () => { + it('renders HTML without Array.prototype.toSorted for Hermes clients', async () => { + const originalToSorted = Array.prototype.toSorted; + // eslint-disable-next-line no-extend-native -- the test reproduces the Hermes runtime without toSorted. + Object.defineProperty(Array.prototype, 'toSorted', { + configurable: true, + value: undefined, + writable: true, + }); + + try { + const renderer = await mount(); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(1); + } finally { + // eslint-disable-next-line no-extend-native -- restore the runtime after the Hermes simulation. + Object.defineProperty(Array.prototype, 'toSorted', { + configurable: true, + value: originalToSorted, + writable: true, + }); + } + }); + + it('keeps an empty value on the Markdown renderer path', async () => { + const renderer = await mount(); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + expect(renderer.root.findAllByType(ViewType)).toHaveLength(2); + expect(vi.mocked(useMarkdown)).not.toHaveBeenCalled(); + }); + + it('keeps plain Markdown and fenced HTML on the existing renderer path', async () => { + const value = 'Hello **world**\n\n```html\n
code only
\n```'; + const renderer = await mount(); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + expect(renderer.root.findAllByType(ViewType)).toHaveLength(3); + expect(vi.mocked(useMarkdown)).toHaveBeenCalledWith(value, expect.any(Object)); + expect(vi.mocked(MarkedLexer)).toHaveBeenCalledTimes(2); + + await act(async () => { + await Promise.resolve(); + renderer.update(); + }); + expect(vi.mocked(MarkedLexer)).toHaveBeenCalledTimes(2); + }); + + it('keeps the markdown prefix mounted when the first HTML token arrives', async () => { + const renderer = await mount(); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + expect(vi.mocked(MarkdownRenderer)).toHaveBeenCalledTimes(1); + + await act(async () => { + await Promise.resolve(); + renderer.update('} />); + }); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(1); + expect(renderer.root.findAllByType(RenderHTMLType)[0]?.props.source).toEqual({ + html: '', + }); + // A root type change would remount the markdown prefix and construct a + // fresh renderer for the unchanged segment; streaming must keep the + // original instance so element keys and local state survive. + expect(vi.mocked(MarkdownRenderer)).toHaveBeenCalledTimes(1); + }); + + it('keeps Markdown blocks on their renderer and keeps inline HTML in one flow', async () => { + const value = + '# Heading\n\nBefore HTML and **Markdown**.\n\n- one\n- two\n\n[Docs](https://example.com)\n\n'; + const renderer = await mount(); + const htmlNodes = renderer.root.findAllByType(RenderHTMLType); + + expect(vi.mocked(useMarkdown).mock.calls.map(([source]) => source)).toEqual([ + '# Heading\n\n', + '\n\n- one\n- two\n\n[Docs](https://example.com)\n\n', + ]); + expect(htmlNodes.map(node => node.props.source)).toEqual([ + { html: '

Before HTML and Markdown.

\n' }, + { html: '' }, + ]); + const props = htmlNodes[0]?.props as RenderHtmlHostProps; + expect(props.baseStyle).toMatchObject({ color: '#111111', fontSize: 16, lineHeight: 24 }); + expect(props.defaultTextProps).toEqual({ selectable: true }); + + await act(async () => { + await Promise.resolve(); + renderer.update(); + }); + expect(renderer.root.findAllByType(RenderHTMLType)[0]?.props.source).toBe(props.source); + }); + + it('routes inline HTML inside a Markdown heading', async () => { + const renderer = await mount(); + + expect(vi.mocked(useMarkdown)).not.toHaveBeenCalled(); + expect(renderer.root.findAllByType(RenderHTMLType).map(node => node.props.source)).toEqual([ + { html: '

Heading HTML

\n' }, + ]); + expect(htmlProps(renderer).tagsStyles).toMatchObject({ + h1: { fontSize: 22, fontWeight: '700' }, + h2: { fontSize: 20, fontWeight: '700' }, + h3: { fontSize: 18, fontWeight: '700' }, + h4: { fontSize: 16, fontWeight: '700' }, + h5: { fontSize: 15, fontWeight: '700' }, + h6: { fontSize: 14, fontWeight: '700' }, + }); + }); + + it('styles HTML text like equivalent Markdown text', async () => { + const renderer = await mount( + + ); + + expect(htmlProps(renderer).tagsStyles).toMatchObject({ + a: { color: '#111111', textDecorationLine: 'underline' }, + blockquote: { borderStartColor: '#cccccc', borderStartWidth: 3, paddingStart: 12 }, + p: { marginVertical: 2, paddingVertical: 0 }, + strong: { color: '#111111', fontWeight: '700' }, + }); + }); + + it('does not match raw HTML inside a preceding code span', async () => { + const renderer = await mount(); + + expect(vi.mocked(useMarkdown)).not.toHaveBeenCalled(); + expect(renderer.root.findAllByType(RenderHTMLType).map(node => node.props.source)).toEqual([ + { html: '

Before <span> HTML after

\n' }, + ]); + }); + + it('keeps inline HTML inside a blockquote on the Markdown path', async () => { + const value = '>
quoted
'; + const renderer = await mount(); + + expect(vi.mocked(useMarkdown).mock.calls.map(([source]) => source)).toEqual([value]); + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + }); + + it('routes HTML links and strong text nested in a list item to the styled HTML renderer', async () => { + const value = + '- Markdown: [example](https://example.com)\n- HTML:
HTML link\n- HTML strong'; + const renderer = await mount(); + const props = htmlProps(renderer); + + expect(props.source.html).toContain('
    '); + expect(props.source.html).toContain('HTML link'); + expect(props.source.html).toContain('HTML strong'); + expect(props.tagsStyles).toMatchObject({ + a: { textDecorationLine: 'underline' }, + strong: { fontWeight: '700' }, + }); + expect(vi.mocked(useMarkdown)).not.toHaveBeenCalled(); + }); + + it('routes HTML headings nested in a list item to the styled HTML renderer', async () => { + const renderer = await mount(HTML heading\n- text'} />); + + expect(htmlProps(renderer).source.html).toContain('

    HTML heading

    '); + }); + + it('routes styled inline HTML inside a blockquote to the styled HTML renderer', async () => { + const value = '> HTML link and HTML strong'; + const renderer = await mount(); + const props = htmlProps(renderer); + + expect(props.source.html).toContain('
    '); + expect(props.source.html).toContain('HTML link'); + expect(props.tagsStyles).toMatchObject({ a: { textDecorationLine: 'underline' } }); + }); + + it('keeps a list with a fenced code block on the Markdown renderer', async () => { + const value = + '- item HTML link\n\n ```js\n const a = 1;\n ```\n'; + const renderer = await mount(); + + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + expect(vi.mocked(useMarkdown).mock.calls.map(([source]) => source)).toContain(value); + }); + + it.each([ + ['link', '[bold](https://example.com)'], + ['emphasis', '*bold*'], + ['strong', '**bold**'], + ])('keeps inline HTML inside Markdown %s on the Markdown path', async (_name, value) => { + const renderer = await mount(); + + expect(vi.mocked(useMarkdown).mock.calls.map(([source]) => source)).toEqual([value]); + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + }); + + it('keeps a table with inline HTML on the table path', async () => { + const value = '| Path | Note |\n| ---- | ---- |\n| a/b | line1
    line2 |'; + const renderer = await mount(); + + expect(renderer.root.findAllByType(MarkdownTableType)).toHaveLength(1); + expect(renderer.root.findAllByType(RenderHTMLType)).toHaveLength(0); + }); + + it('keeps tables and fenced code around block HTML on the Markdown path', async () => { + const value = + '| Name |\n| --- |\n| Kilo |\n\n
    safe HTML
    \n\n```ts\nconst answer = 42;\n```'; + const renderer = await mount(); + + expect(renderer.root.findAllByType(MarkdownTableType)).toHaveLength(1); + expect(renderer.root.findAllByType(RenderHTMLType).map(node => node.props.source)).toEqual([ + { html: '
    safe HTML
    ' }, + ]); + expect(vi.mocked(useMarkdown).mock.calls.map(([source]) => source)).toContain( + '\n\n```ts\nconst answer = 42;\n```' + ); + }); + + it('routes block HTML and removes active, style, form, media, SVG, and metadata nodes', async () => { + const value = + '
    safe
    object text
    form text
    svg textmeta text'; + const renderer = await mount(); + const props = htmlProps(renderer); + + expect(props.source.html).toContain('safe'); + expect(props.enableCSSInlineProcessing).toBe(false); + expect(props.ignoredDomTags).toEqual( + expect.arrayContaining(['link', 'frame', 'embed', 'source', 'track', 'input', 'base', 'meta']) + ); + expect(props.source).not.toHaveProperty('uri'); + + const actual = await vi.importActual('react-native-render-html'); + const engine = actual.buildTREFromConfig({ + baseStyle: props.baseStyle, + domVisitors: props.domVisitors, + enableCSSInlineProcessing: props.enableCSSInlineProcessing, + ignoredDomTags: props.ignoredDomTags, + }); + expect(visibleText(engine.buildTTree(props.source.html))).toBe('safe'); + }); + + it('keeps a picture fallback image while clearing its removed children', async () => { + const renderer = await mount( + + ); + const props = htmlProps(renderer); + const actual = await vi.importActual('react-native-render-html'); + const engine = actual.buildTREFromConfig({ + baseStyle: props.baseStyle, + domVisitors: props.domVisitors, + enableCSSInlineProcessing: props.enableCSSInlineProcessing, + ignoredDomTags: props.ignoredDomTags, + }); + const images: TNode[] = []; + const visit = (node: TNode): void => { + if (node.tagName === 'img') { + images.push(node); + } + for (const child of node.children) { + visit(child); + } + }; + visit(engine.buildTTree(props.source.html)); + + expect(images).toHaveLength(1); + expect(images[0]?.attributes.src).toBe('https://example.com/a.png'); + expect(visibleText(engine.buildTTree(props.source.html))).toBe(''); + }); + + it('renders an active-content-only source as an empty native tree', async () => { + const renderer = await mount(); + const props = htmlProps(renderer); + const actual = await vi.importActual('react-native-render-html'); + const engine = actual.buildTREFromConfig({ + domVisitors: props.domVisitors, + ignoredDomTags: props.ignoredDomTags, + }); + + expect(visibleText(engine.buildTTree(props.source.html))).toBe(''); + }); +}); + +describe('MarkdownText HTML links and images', () => { + it('routes a linked image press with the link accessibility label', async () => { + const onPressLink = vi.fn(() => true); + const value = 'Text shot'; + const renderer = await mount(); + await act(async () => { + await Promise.resolve(); + renderer.update(); + }); + const ImageRenderer = requiredRenderer(htmlProps(renderer).renderers, 'img'); + const image = await renderCustom(ImageRenderer, { + attributes: { src: 'https://example.com/a.png', alt: 'shot' }, + parent: { + tagName: 'a', + attributes: { href: 'https://example.com', title: 'Example' }, + parent: null, + }, + }); + const imageProps = image.root.findByType(MarkdownImageType).props as Record; + + expect(imageProps.accessibilityLabel).toBe('Example'); + expect(imageProps.onPress).toBeTypeOf('function'); + (imageProps.onPress as () => void)(); + expect(onPressLink).toHaveBeenCalledWith('https://example.com'); + expect(confirmAndOpenMarkdownLink).not.toHaveBeenCalled(); + }); + + it('routes anchor press and long press without forwarding executable attributes', async () => { + const onPressLink = vi.fn(() => true); + const onLongPressLink = vi.fn<(href: string, event?: GestureResponderEvent) => void>(); + const renderer = await mount( + + ); + const props = htmlProps(renderer); + const onPress = props.renderersProps.a?.onPress; + if (!onPress) { + throw new Error('anchor press handler was not created'); + } + const event = undefined as never; + onPress(event, 'https://example.com', { title: 'Docs' }, '_blank'); + expect(onPressLink).toHaveBeenCalledWith('https://example.com'); + expect(confirmAndOpenMarkdownLink).not.toHaveBeenCalled(); + onPressLink.mockReturnValue(false); + onPress(event, 'https://example.com', { title: 'Docs' }, '_blank'); + expect(confirmAndOpenMarkdownLink).toHaveBeenCalledWith('https://example.com', { + label: 'Docs', + }); + + const anchor = await renderCustom( + requiredRenderer(props.renderers, 'a'), + { attributes: { href: 'https://example.com', onclick: 'run()' } }, + { InternalRenderer: 'Anchor', textProps: {} } + ); + const textProps = anchor.root.findByType(AnchorType).props.textProps as Record; + expect(textProps).not.toHaveProperty('onclick'); + expect(textProps).not.toHaveProperty('onClick'); + (textProps.onLongPress as (event: never) => void)(event); + expect(onLongPressLink).toHaveBeenCalledWith('https://example.com', undefined); + }); + + it.each([ + ['missing dimensions', { src: 'https://example.com/a.png' }], + ['unparsable dimensions', { src: 'https://example.com/a.png', width: '400px', height: '900' }], + ['empty height', { src: 'https://example.com/a.png', width: '400', height: '' }], + ['zero width', { src: 'https://example.com/a.png', width: '0', height: '900' }], + ['negative width', { src: 'https://example.com/a.png', width: '-400', height: '900' }], + ])('leaves the aspect ratio to onLoad measurement: %s', async (_name, attributes) => { + const renderer = await mount(); + const ImageRenderer = requiredRenderer(htmlProps(renderer).renderers, 'img'); + const rendered = await renderCustom(ImageRenderer, { attributes, parent: null }); + expect(rendered.root.findByType(MarkdownImageType).props.aspectRatio).toBeUndefined(); + }); + + it('keeps a valid portrait dimension pair on the clamped ratio path', async () => { + const renderer = await mount(); + const ImageRenderer = requiredRenderer(htmlProps(renderer).renderers, 'img'); + const portrait = await renderCustom(ImageRenderer, { + attributes: { src: 'https://example.com/a.png', width: '1170', height: '2532' }, + parent: null, + }); + expect(portrait.root.findByType(MarkdownImageType).props.aspectRatio).toBe(0.75); + }); + + it('routes supported images with a fixed ratio and renders unsupported alt text', async () => { + const renderer = await mount( + + ); + const ImageRenderer = requiredRenderer(htmlProps(renderer).renderers, 'img'); + const supported = await renderCustom(ImageRenderer, { + attributes: { + src: 'https://example.com/a.png', + alt: 'shot', + width: '400', + height: '200', + }, + parent: null, + }); + expect(supported.root.findByType(MarkdownImageType).props).toMatchObject({ + uri: 'https://example.com/a.png', + alt: 'shot', + aspectRatio: 2, + }); + + const http = await renderCustom(ImageRenderer, { + attributes: { src: 'http://example.com/a.png' }, + parent: null, + }); + const data = await renderCustom(ImageRenderer, { + attributes: { src: 'data:image/png;base64,abc' }, + parent: null, + }); + expect([ + http.root.findByType(MarkdownImageType).props.uri, + data.root.findByType(MarkdownImageType).props.uri, + ]).toEqual(['http://example.com/a.png', 'data:image/png;base64,abc']); + + const unsupported = await renderCustom(ImageRenderer, { + attributes: { src: 'file:///secret.png', alt: 'diagram' }, + parent: null, + }); + const textProps = unsupported.root.findByType(TextType).props; + expect(textProps).toMatchObject({ + children: 'diagram', + selectable: true, + }); + expect(textProps).not.toHaveProperty('onPress'); + expect(unsupported.root.findAllByType(MarkdownImageType)).toHaveLength(0); + + const empty = await renderCustom(ImageRenderer, { + attributes: { src: '' }, + parent: null, + }); + expect(empty.root.findByType(TextType).props.children).toBe(''); + expect(empty.root.findAllByType(MarkdownImageType)).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx index 77448112f6..2ed570fc8e 100644 --- a/apps/mobile/src/components/agents/markdown-text.tsx +++ b/apps/mobile/src/components/agents/markdown-text.tsx @@ -4,6 +4,7 @@ import { useMarkdown } from 'react-native-marked'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { MarkdownHtml, splitMarkdownHtml } from './markdown-html'; import { getMarkdownStyles, getPalette, @@ -42,7 +43,50 @@ export function MarkdownText({ const colors = useThemeColors(); const palette = useMemo(() => getPalette(variant, colors), [variant, colors]); + const segments = useMemo(() => splitMarkdownHtml(value), [value]); + // Always render through the same wrapping View with index keys: switching to + // a bare MarkdownContent when no HTML token exists would change the root + // element type, remounting the markdown prefix (and wiping its table + // snapshot and CodeBlock keys) as soon as the first HTML token streams in. + return ( + + {segments.map((segment, index) => + segment.type === 'html' ? ( + + ) : ( + + ) + )} + + ); +} + +type MarkdownContentProps = Omit & { + palette: MarkdownPalette; +}; + +function MarkdownContent({ + value, + palette, + selectable = true, + onLongPressLink, + onPressLink, +}: Readonly) { // Tables are extracted before any renderer runs: each table becomes a chip // (parsed on open), and the remaining markdown runs render through useMarkdown. const [snapshot, setSnapshot] = useState(() => ({ value, segments: splitMarkdownTables(value) })); diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts index 2d2f56f131..286cc8973f 100644 --- a/apps/mobile/src/components/agents/message-bubble.test.ts +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -205,6 +205,15 @@ function assistantMessageWithError(id: string, errorName: string): StoredMessage } describe('MessageBubble failure footer', () => { + it('wraps the failed bubble and delivery row in one measurable list item', async () => { + const tree = await renderBubbleWithHandlers(userMessage('m-measured'), { + deliveryState: { status: 'failed', error: 'nope', reason: 'exhausted' }, + onRetryMessage: vi.fn<(message: StoredMessage) => void>(), + }); + + expect((tree as { type?: unknown }).type).toBe('View'); + }); + it('renders the failed-delivery footer with Retry and Copy to composer', async () => { const tree = await renderBubbleWithHandlers(userMessage('m-fail'), { deliveryState: { status: 'failed', error: 'nope', reason: 'exhausted' }, @@ -227,6 +236,34 @@ describe('MessageBubble failure footer', () => { expect(copy?.props.accessibilityRole).toBe('button'); }); + it('renders only the failure footer when the user text sanitizes to empty', async () => { + const actual = await vi.importActual('./part-types'); + const { isTextPart } = await import('./part-types'); + vi.mocked(isTextPart).mockImplementation(actual.isTextPart); + const { Bubble: MockBubble } = await import('@/components/ui/bubble'); + const message = userMessage('m-sanitized'); + const textPart = message.parts[0]; + if (textPart?.type !== 'text') { + throw new Error('expected text part'); + } + textPart.text = ''; + + try { + const tree = await renderBubbleWithHandlers(message, { + deliveryState: { status: 'failed', error: 'nope', reason: 'exhausted' }, + onRetryMessage: vi.fn<(value: StoredMessage) => void>(), + }); + + expect(findElementByTypeFn(tree, MockBubble)).toBeNull(); + expect(findText(tree, text => text === 'Failed to deliver')).toBe(true); + expect( + findElementByType(tree, 'Button', props => props.accessibilityLabel === 'Retry') + ).not.toBeNull(); + } finally { + vi.mocked(isTextPart).mockReturnValue(false); + } + }); + it('renders the assistant failure footer with Retry and no Copy to composer', async () => { const tree = await renderBubbleWithHandlers(assistantMessageWithError('m-asst', 'APIError'), { onRetryMessage: vi.fn<(message: StoredMessage) => void>(), diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index ba75319b28..bb7a8152ae 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -17,6 +17,7 @@ import { collectCopyableText } from './collect-copyable-text'; import { FilePartRenderer } from './file-part-renderer'; import { buildAgentMessageBubbleAccessibilityProps } from './message-bubble-a11y'; import { selectMessageFailure } from './message-failure-state'; +import { partRendersContent } from './message-visibility'; import { PartRenderer } from './part-renderer'; import { firstHumanText, isFilePart, isTextPart } from './part-types'; import { useMessageCopy } from './use-message-copy'; @@ -173,23 +174,25 @@ function MessageBubbleImpl({ const hasBadgeSlot = isQueued || holdQueuedSlot; return ( - <> + - - - {userTextContent ? ( - - ) : null} - {fileParts.map(part => ( - - ))} - - + {message.parts.some(partRendersContent) ? ( + + + {userTextContent ? ( + + ) : null} + {fileParts.map(part => ( + + ))} + + + ) : null} {hasBadgeSlot || onRestoreQueued ? ( {hasBadgeSlot ? ( @@ -243,7 +246,7 @@ function MessageBubbleImpl({ ) : null} {failureFooter} - + ); } @@ -254,7 +257,7 @@ function MessageBubbleImpl({ const isStreaming = isLastAssistantMessage && isSessionStreaming; return ( - <> + @@ -285,7 +288,7 @@ function MessageBubbleImpl({ ) : null} {failureFooter} - + ); } diff --git a/apps/mobile/src/components/agents/message-visibility.test.ts b/apps/mobile/src/components/agents/message-visibility.test.ts index 340c4c2bf3..bd6713357c 100644 --- a/apps/mobile/src/components/agents/message-visibility.test.ts +++ b/apps/mobile/src/components/agents/message-visibility.test.ts @@ -180,4 +180,37 @@ describe('messageRendersContent', () => { }; expect(messageRendersContent(user)).toBe(true); }); + + it('returns false for a user message whose only text is removed during HTML sanitization', () => { + const user: StoredMessage = { + info: { + id: 'm1', + sessionID: 's1', + role: 'user', + time: { created: 1 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'model' }, + }, + parts: [textPart({ text: "" })], + }; + expect(messageRendersContent(user)).toBe(false); + }); + + it.each([ + '', + '
    ', + '', + '
    ', + '
    ', + '

    ', + ])('returns false when sanitization removes %s', text => { + expect(partRendersContent(textPart({ text }))).toBe(false); + }); + + it.each(['before', '
    safe
    ', '
    '])( + 'keeps visible content in %s', + text => { + expect(partRendersContent(textPart({ text }))).toBe(true); + } + ); }); diff --git a/apps/mobile/src/components/agents/message-visibility.ts b/apps/mobile/src/components/agents/message-visibility.ts index 02ae6bd786..6c88caabbb 100644 --- a/apps/mobile/src/components/agents/message-visibility.ts +++ b/apps/mobile/src/components/agents/message-visibility.ts @@ -10,6 +10,7 @@ import { isToolPart, shouldRenderReasoningPart, } from './part-types'; +import { htmlSanitizesToEmpty } from './markdown-html-sanitization'; /** * Whether `PartRenderer` renders visible content for this part. @@ -24,7 +25,9 @@ export function partRendersContent(part: Part): boolean { // TextPartRenderer renders nothing for blank text. Whitespace-only text is // blank: markdown draws no ink for it, so counting it as content adds a // zero-height row that eats a transcript gap and doubles the visible one. - return !isSnapshotProgressPart(part) && part.text.trim() !== ''; + return ( + !isSnapshotProgressPart(part) && part.text.trim() !== '' && !htmlSanitizesToEmpty(part.text) + ); } if (isToolPart(part)) { // ToolPartRenderer renders nothing for the plan-mode transition tools. @@ -39,9 +42,12 @@ export function partRendersContent(part: Part): boolean { } /** - * Whether the message renders anything in the transcript. A user message always - * renders its bubble; an assistant message renders only what its parts render. + * Whether the message renders anything in the transcript. Keep the transient + * zero-part user row, but drop a user row whose parts sanitize to no content. */ export function messageRendersContent(message: StoredMessage): boolean { - return message.info.role === 'user' || message.parts.some(partRendersContent); + return ( + (message.info.role === 'user' && message.parts.length === 0) || + message.parts.some(partRendersContent) + ); } diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 4663e3aa84..76c4037ee7 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -545,9 +545,12 @@ describe('SessionDetailContent display scope', () => { }); const header = renderer.root.findByType(ScreenHeader); expect(header.findByProps({ accessibilityRole: 'header' }).props).toMatchObject({ - numberOfLines: 1, + numberOfLines: 2, ellipsizeMode: 'tail', }); + expect(header.findByProps({ accessibilityRole: 'header' }).parent?.props.className).toContain( + 'min-h-14' + ); expect(header.props.context).toBeUndefined(); expect(header.findAllByType(ContextControl)).toHaveLength(0); expect( @@ -656,9 +659,12 @@ describe.each([true, false])('session detail return with history=%s', hasHistory const header = view.renderer.root.findByType(ScreenHeader); expect(header.findByProps({ accessibilityRole: 'header' }).props).toMatchObject({ - numberOfLines: 1, + numberOfLines: 2, ellipsizeMode: 'tail', }); + expect(header.findByProps({ accessibilityRole: 'header' }).parent?.props.className).toContain( + 'min-h-14' + ); pressHeaderBack(view.renderer); expect(navigationRoutes).toEqual( hasHistory ? ['previous-screen'] : ['/(app)/(tabs)/(2_agents)'] diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 80199c8f44..3649a1e416 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -760,8 +760,8 @@ export function SessionDetailContent({ detailsBusy && isQueuedCancellationEligible(detailsMessage, detailsDelivery, false); const transcript = useMemo( - () => mergeSessionTranscript(visibleMessages, preparationAttempts), - [visibleMessages, preparationAttempts] + () => mergeSessionTranscript(visibleMessages, preparationAttempts, pendingMessages), + [visibleMessages, preparationAttempts, pendingMessages] ); // Render-phase state adjustment: hold queued ids across queue → dequeue @@ -1391,7 +1391,7 @@ export function SessionDetailContent({ { expect(keysOf(withInvisible)).toEqual(keysOf(withoutInvisible)); }); + it('keeps a sanitized-empty user message when its delivery failed', () => { + const failedMessage = userMessageWithText('msg_failed', ''); + + const transcript = mergeSessionTranscript( + [failedMessage], + [], + new Map([[failedMessage.info.id, { status: 'failed', error: 'nope', reason: 'exhausted' }]]) + ); + + expect(keysOf(transcript)).toEqual(['time:msg_failed', 'msg_failed']); + }); + it('keeps an invalid-timestamp message visible without a marker and without resetting the run', () => { const base = 1_000_000_000; const transcript = mergeSessionTranscript( diff --git a/apps/mobile/src/components/agents/session-transcript.ts b/apps/mobile/src/components/agents/session-transcript.ts index 838e62623b..ea9066de23 100644 --- a/apps/mobile/src/components/agents/session-transcript.ts +++ b/apps/mobile/src/components/agents/session-transcript.ts @@ -1,5 +1,9 @@ import { isNoOpCompletedPreparationAttempt } from '@kilocode/cloud-agent-sdk/preparation-attempts'; -import { type PreparationAttempt, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { + type MessageDeliveryState, + type PreparationAttempt, + type StoredMessage, +} from '@kilocode/cloud-agent-sdk'; import { isSameLocalDay, isValidTranscriptTime } from './message-time-label'; import { messageRendersContent } from './message-visibility'; @@ -34,7 +38,8 @@ export function getSessionTranscriptItemType(item: SessionTranscriptItem): strin export function mergeSessionTranscript( messages: readonly StoredMessage[], - preparationAttempts: readonly PreparationAttempt[] + preparationAttempts: readonly PreparationAttempt[], + deliveryStates?: ReadonlyMap ): SessionTranscriptItem[] { // `ensureWrapper` records a completed attempt for every message delivery, // even warm reuse. Drop no-op completed attempts so "Environment prepared" @@ -55,7 +60,10 @@ export function mergeSessionTranscript( let previousCreated: number | undefined = undefined; for (const message of messages) { messageIds.add(message.info.id); - if (messageRendersContent(message)) { + if ( + messageRendersContent(message) || + deliveryStates?.get(message.info.id)?.status === 'failed' + ) { const created = message.info.time.created; // One validity rule, shared with the marker component: a timestamp the label // cannot format must never produce a marker row. diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.mounted.test.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.mounted.test.tsx new file mode 100644 index 0000000000..88e99184fd --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-bubble.mounted.test.tsx @@ -0,0 +1,181 @@ +/* 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 { type KiloChatClient, type Message } from '@kilocode/kilo-chat'; +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { MessageBubble } from './message-bubble'; + +vi.mock('expo-crypto', () => ({ + getRandomValues: (typedArray: Uint8Array) => { + typedArray[0] = 128; + return typedArray; + }, +})); +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', + Platform: { OS: 'ios' }, +})); +vi.mock('react-native-gesture-handler', () => { + const chainable: Record = {}; + for (const method of ['activeOffsetX', 'onUpdate', 'onEnd', 'onFinalize']) { + chainable[method] = () => chainable; + } + return { Gesture: { Pan: () => chainable }, GestureDetector: 'GestureDetector' }; +}); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + Easing: { out: (f: unknown) => f, cubic: 'cubic' }, + useAnimatedStyle: () => ({}), + useSharedValue: () => ({ value: 0 }), + withSequence: (...values: unknown[]) => values[0], + withTiming: (value: unknown) => value, +})); +vi.mock('react-native-worklets', () => ({ scheduleOnRN: vi.fn() })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/i18n', () => ({ i18n: { language: 'en', t: (key: string) => key } })); +vi.mock('@/lib/intl-cache', () => ({ + dateTimeFormat: (locale: string, options: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat(locale, options), +})); +vi.mock('@/lib/utils', () => ({ + cn: (...inputs: unknown[]) => inputs.filter(Boolean).join(' '), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + foreground: '#111111', + primaryForeground: '#111111', + mutedForeground: '#666666', + destructive: '#ff0000', + }), +})); +vi.mock('@/components/ui/icons', () => ({ Reply: 'Reply' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('./message-bubble-content', () => ({ MessageBubbleContent: 'MessageBubbleContent' })); +vi.mock('./message-reaction-pills', () => ({ MessageReactionPills: 'MessageReactionPills' })); + +function message(overrides: Partial = {}): Message { + return { + id: 'message-1', + senderId: 'user-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: 1_800_000_000_000, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + ...overrides, + }; +} + +function mountBubble(props: { + message: Message; + isFromMe: boolean; + replyToMessage?: Message | null; +}): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + createElement(MessageBubble, { + client: undefined as unknown as KiloChatClient, + conversationId: 'c1', + currentUserId: 'user-1', + showAuthor: false, + authorLabel: 'Igor', + pendingActionGroupId: null, + onExecuteAction: () => undefined, + onReactionPress: () => undefined, + ...props, + }) + ); + }); + if (!ref.current) { + throw new Error('renderer was not created'); + } + return ref.current; +} + +function classNames(root: TestRenderer.ReactTestInstance): string[] { + return root + .findAll(node => typeof node.type === 'string') + .map(node => { + const className = (node.props as { className?: unknown }).className; + return typeof className === 'string' ? className : ''; + }); +} + +describe('MessageBubble visible-content gate', () => { + it('renders no yellow bubble when the user text sanitizes to empty', () => { + const renderer = mountBubble({ + message: message({ content: [{ type: 'text', text: "" }] }), + isFromMe: true, + }); + + const classes = classNames(renderer.root); + expect(classes.some(c => c.includes('bg-primary'))).toBe(false); + expect(renderer.root.findAllByType('MessageBubbleContent' as never)).toHaveLength(0); + expect(renderer.root.children).toHaveLength(0); + }); + + it('renders the yellow bubble for a normal user message', () => { + const renderer = mountBubble({ message: message(), isFromMe: true }); + + expect(classNames(renderer.root).some(c => c.includes('bg-primary'))).toBe(true); + expect(renderer.root.findAllByType('MessageBubbleContent' as never)).toHaveLength(1); + }); + + it('renders no empty assistant bubble when the text sanitizes to empty', () => { + const renderer = mountBubble({ + message: message({ + content: [{ type: 'text', text: '' }], + }), + isFromMe: false, + }); + + expect(classNames(renderer.root).some(c => c.includes('bg-card'))).toBe(false); + expect(renderer.root.children).toHaveLength(0); + }); + + it('renders no yellow bubble when blocked tags are wrapped in plain containers', () => { + const renderer = mountBubble({ + message: message({ + content: [ + { type: 'text', text: '
    ' }, + ], + }), + isFromMe: true, + }); + + expect(classNames(renderer.root).some(c => c.includes('bg-primary'))).toBe(false); + expect(renderer.root.children).toHaveLength(0); + }); + + it('keeps the bubble when a sanitized text block sits next to a visible one', () => { + const renderer = mountBubble({ + message: message({ + content: [ + { type: 'text', text: "" }, + { type: 'text', text: 'still here' }, + ], + }), + isFromMe: true, + }); + + expect(classNames(renderer.root).some(c => c.includes('bg-primary'))).toBe(true); + }); + + it('keeps the bubble when a sanitized text message failed delivery', () => { + const renderer = mountBubble({ + message: message({ + content: [{ type: 'text', text: "" }], + deliveryFailed: true, + }), + isFromMe: true, + }); + + expect(classNames(renderer.root).some(c => c.includes('bg-primary'))).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index cb70607802..88caff3451 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -27,7 +27,11 @@ import { SWIPE_REPLY_DISTANCE, SWIPE_REPLY_MAX_TRANSLATE, } from './message-gesture-state'; -import { isMessageEdited, type ReplyPreviewSource } from './message-presentation'; +import { + isMessageEdited, + messageRendersBubble, + type ReplyPreviewSource, +} from './message-presentation'; import { MessageReactionPills } from './message-reaction-pills'; type Props = { @@ -177,6 +181,13 @@ function MessageBubbleComponent({ opacity: longPressHighlight.value, })); + // A message whose text is entirely removed by HTML sanitization renders no + // ink; wrapping nothing would leave a tiny empty bubble (spot-check defect + // e9). Drop the whole row, matching the agent transcript's content gate. + if (!messageRendersBubble(message, { hasReplyPreview: replyToMessage != null })) { + return null; + } + return ( ) { - if (text.trim().length === 0) { + if (!textBlockHasVisibleContent(text)) { return null; } diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index 4c12bb8234..e8a856abaf 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -17,6 +17,7 @@ import { getVisibleEditableAttachmentBlocks, isMessageEdited, isMessageTextSelectionEnabled, + messageRendersBubble, resolveMessageAuthorLabel, } from './message-presentation'; @@ -321,3 +322,114 @@ describe('resolveMessageAuthorLabel', () => { expect(resolveMessageAuthorLabel({ senderId: 'user-1' })).toBe('user-1'); }); }); + +describe('messageRendersBubble', () => { + it('hides the bubble when the only text sanitizes to empty', () => { + expect( + messageRendersBubble( + message({ content: [{ type: 'text', text: "" }] }), + { hasReplyPreview: false } + ) + ).toBe(false); + }); + + it.each([ + '', + '
    ', + '', + '
    ', + '
    ', + ])('hides the bubble when %s sanitizes to empty', text => { + expect( + messageRendersBubble(message({ content: [{ type: 'text', text }] }), { + hasReplyPreview: false, + }) + ).toBe(false); + }); + + it('keeps the bubble when text survives sanitization', () => { + expect( + messageRendersBubble( + message({ content: [{ type: 'text', text: 'before' }] }), + { + hasReplyPreview: false, + } + ) + ).toBe(true); + expect( + messageRendersBubble( + message({ content: [{ type: 'text', text: '
    safe
    ' }] }), + { + hasReplyPreview: false, + } + ) + ).toBe(true); + }); + + it('keeps the bubble when a sanitized text block is mixed with a visible one', () => { + expect( + messageRendersBubble( + message({ + content: [ + { type: 'text', text: "" }, + { type: 'text', text: 'still here' }, + ], + }), + { hasReplyPreview: false } + ) + ).toBe(true); + }); + + it('keeps the bubble for attachment blocks and action groups', () => { + expect( + messageRendersBubble( + message({ + content: [ + { + type: 'attachment', + attachmentId: 'att-1', + mimeType: 'image/png', + size: 1, + filename: 'a.png', + }, + ], + }), + { hasReplyPreview: false } + ) + ).toBe(true); + expect( + messageRendersBubble( + message({ + content: [ + { + type: 'actions', + groupId: 'g1', + actions: [{ label: 'Allow', value: 'allow-once', style: 'primary' }], + }, + ], + }), + { hasReplyPreview: false } + ) + ).toBe(true); + }); + + it('keeps the bubble for deleted and delivery-failed messages and reply previews', () => { + expect( + messageRendersBubble(message({ deleted: true, content: [] }), { hasReplyPreview: false }) + ).toBe(true); + expect( + messageRendersBubble(message({ deliveryFailed: true, content: [] }), { + hasReplyPreview: false, + }) + ).toBe(true); + expect( + messageRendersBubble(message({ content: [{ type: 'text', text: '' }] }), { + hasReplyPreview: true, + }) + ).toBe(true); + }); + + it('hides the bubble for a message with no content blocks at all', () => { + expect(messageRendersBubble(message({ content: [] }), { hasReplyPreview: false })).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index 38cd820bb4..86b25d822c 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -13,6 +13,8 @@ import { ulid } from 'ulid'; import { i18n } from '@/i18n'; +import { htmlSanitizesToEmpty } from '../agents/markdown-html-sanitization'; + type SendMessageVariables = CreateMessageRequest & { clientId: string }; export type ReplyPreviewSource = Message | ReplyToMessageSnapshot; export type MessageAuthorMember = ConversationDetailResponse['members'][number]; @@ -96,6 +98,36 @@ export function isMessageTextSelectionEnabled(): boolean { return false; } +/** + * Whether a text block renders any ink. The HTML renderer strips blocked tags + * entirely, so a message that is only blocked tags must not reserve a bubble. + */ +export function textBlockHasVisibleContent(text: string): boolean { + return text.trim() !== '' && !htmlSanitizesToEmpty(text); +} + +/** + * Whether the bubble wrapper has anything visible to wrap. Mirrors the agent + * transcript gate (`messageRendersContent`): a message whose text sanitizes to + * empty renders no bubble at all instead of a tiny empty one. + */ +export function messageRendersBubble( + message: Message, + options: { hasReplyPreview: boolean } +): boolean { + if (message.deleted) { + // The bubble renders the "deleted" label instead of the content. + return true; + } + if (options.hasReplyPreview || message.deliveryFailed) { + // The reply preview and the delivery-failure footer live inside the bubble. + return true; + } + return message.content.some(block => + block.type === 'text' ? textBlockHasVisibleContent(block.text) : true + ); +} + export function canShowReactionPills(message: Message): boolean { return !message.deleted && message.reactions.length > 0; } diff --git a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx index b59bcafe20..111a067212 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx @@ -344,7 +344,7 @@ describe('OrganizationCreditActivityScreen pagination', () => { const texts = await renderScreen(); expect(texts).toContain('Top-up'); - expect(texts).toContain("Couldn't load more."); + expect(texts).toContain("Couldn't load more"); expect(texts).not.toContain('Older credit activity is available.'); const retry = buttons.rendered.find(button => button.accessibilityLabel === 'Retry'); diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index bf65514f17..344f66952a 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -234,7 +234,7 @@ export function OrganizationCreditActivityScreen() { {isLaterPageError && ( - {t('organization.invoices.loadMoreFailed')} + {t('common.couldnTLoadMore')}