/** * Response — a model's answer, rendered as it arrives. * * An answer is markdown. It has headings and lists and fenced code in it, * because that is what a model writes, and rendering it as one run of plain * text throws away the structure the model went to the trouble of producing. * * So this reads the markdown and renders it through the library's own parts — * `Typography` for prose, `CodeBlock` for fences, `Table` for tables. Nothing * here draws its own type or its own colours: an answer inside a message bubble * should look like the app it is in, not like a document viewer someone * embedded. * * ```tsx * {text} * ``` * * ## Why it is a whole component and not a `` * * Because the text is still arriving, and that changes everything about how it * has to be read. A token stream hands you every prefix of the final answer, so * a renderer sees `**bo`, then `**bol`, then `**bold**` — three documents, two * of which have literal asterisks in them. Render each faithfully and the * answer flickers between styles on nearly every frame, which is worse than no * formatting at all: the eye tracks the flicker instead of the words. * * `isStreaming` tells the reader to finish an unterminated construct at the end * of the input rather than escaping it — an open fence is a code block that is * still filling, an open `**` is bold text still being written. The rule it * works to is that no word already on screen may disappear when the next token * arrives; delimiters may vanish as they are recognised, words never do. * * ## Where the props come from * * With the AI SDK, `children` is the text of the assistant message's text parts * joined together, and `isStreaming` is `status === 'streaming'`. Join the * parts — one `Response` per part renders a heading in one component and the * paragraph under it in another, and neither knows about the other. */ import { memo, useMemo, type ComponentType, type ReactNode } from 'react'; import { Linking, View, type ViewProps } from 'react-native'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { CodeBlock } from '../code-block'; import { Table } from '../table'; import { Typography } from '../typography'; import { parseMarkdown, type Align, type Block, type InlineToken, } from './markdown'; export type { Block as ResponseBlock, InlineToken as ResponseInline } from './markdown'; /** Heading levels below h4 are h4's size; a chat answer has no use for six. */ const HEADING_TYPE = ['h2', 'h3', 'h4', 'h4', 'h4', 'h4'] as const; /** Schemes a link may open with. Anything else is rendered but not pressable. */ const DEFAULT_LINK_PREFIXES = ['https://', 'http://', 'mailto:', 'tel:']; export interface ResponseComponents { /** Replaces the whole code block — for a runnable snippet, or a diff viewer. */ code?: ComponentType<{ code: string; language?: string; streaming: boolean }>; /** Replaces an image. Nothing is rendered for one by default. */ image?: ComponentType<{ src: string; alt: string }>; } export interface ResponseProps extends Omit { className?: string; /** The markdown. */ children?: string; /** * Whether more is still coming. Finishes an unterminated construct at the end * of the text instead of escaping it, so the answer does not flicker between * styles as its delimiters arrive. */ isStreaming?: boolean; /** Turns off speculative completion entirely, even while streaming. */ parseIncompleteMarkdown?: boolean; /** What a link does. Opens it with the system handler by default. */ onLinkPress?: (href: string) => void; /** * Schemes a link is allowed to open. A model can write any URL it likes, and * an answer is not a trusted document — so the default list is the four that * cannot do anything but navigate. */ allowedLinkPrefixes?: string[]; /** Swap out how a block is drawn. */ components?: ResponseComponents; /** Line numbers in fenced code. */ showLineNumbers?: boolean; } function ResponseRoot({ className, children = '', isStreaming = false, parseIncompleteMarkdown = true, onLinkPress, allowedLinkPrefixes = DEFAULT_LINK_PREFIXES, components, showLineNumbers = false, ...props }: ResponseProps) { const speculate = isStreaming && parseIncompleteMarkdown; const blocks = useMemo( () => parseMarkdown(children, speculate), [children, speculate] ); const context: RenderContext = { streaming: isStreaming, onLinkPress, allowedLinkPrefixes, components, showLineNumbers, }; return ( {blocks.map((block, index) => ( ))} ); } /** * Re-renders only when the text changes. * * A stream re-renders its parent on every token, and everything else the parent * hands down — the callbacks, the overrides — is usually a fresh object each * time. Comparing the one prop that actually decides the output keeps a long * answer from re-parsing because a sibling moved. */ export const Response = memo( ResponseRoot, (previous, next) => previous.children === next.children && previous.isStreaming === next.isStreaming && previous.className === next.className ); Response.displayName = 'Response'; /* -------------------------------------------------------------------------- */ /* Blocks */ /* -------------------------------------------------------------------------- */ interface RenderContext { streaming: boolean; onLinkPress?: (href: string) => void; allowedLinkPrefixes: string[]; components?: ResponseComponents; showLineNumbers: boolean; } function BlockView({ block, context }: { block: Block; context: RenderContext }) { switch (block.type) { case 'heading': return ( {spans(block.inline, context)} ); case 'paragraph': return ( {spans(block.inline, context)} ); case 'code': { const Custom = context.components?.code; if (Custom) { return ( ); } return ( {block.language ? ( {block.language} {/* No copy button while the fence is still open: copying half a snippet is worse than not offering to, because it succeeds. */} {block.open && context.streaming ? null : } ) : null} ); } case 'quote': // The rule is drawn here rather than by `Typography.Blockquote`, which // puts its children inside a `Text` — right for a quoted sentence, wrong // for a quote that turns out to contain a list and a snippet. `border-s` // so the rule moves to the other side under a right-to-left `Direction`. return ( {block.blocks.map((child, index) => ( ))} ); case 'list': return ( {block.items.map((item, index) => ( {item.map((child, position) => ( ))} ))} ); case 'table': return ; case 'rule': return ; } } const ALIGNMENT: Record = { left: 'start', center: 'center', right: 'end', }; /** * A GFM table. * * Every column gets the same share of the width. A markdown table carries no * widths, and guessing them from the longest cell makes the columns jump about * as rows stream in — which is exactly the movement everything else here is * built to avoid. */ function TableBlock({ block, context, }: { block: Extract; context: RenderContext; }) { const align = (index: number) => ALIGNMENT[block.align[index] ?? 'left']; return ( {block.head.map((cell, index) => ( ))} {block.rows.map((row, rowIndex) => ( {block.head.map((_unused, index) => ( ))} ))}
); } /* -------------------------------------------------------------------------- */ /* Inline */ /* -------------------------------------------------------------------------- */ /** * A run of styled spans, inside a `Text` of its own. * * Nested `Text` rather than a row of views, because only nested text wraps as * one paragraph — a bolded word in a view would break the line before it and * leave a gap where the sentence should have continued. * * The wrapper is not optional. Half these spans are bare strings, and a bare * string is only legal inside a `Text`: dropped into a `Table.Cell` — which is * a view — React Native refuses to render it at all. */ function InlineRun({ tokens, context, className, }: { tokens: InlineToken[]; context: RenderContext; className?: string; }) { return {spans(tokens, context)}; } function spans(tokens: InlineToken[], context: RenderContext): ReactNode { return tokens.map((token, index) => { if (token.kind === 'image') { const Custom = context.components?.image; // Nothing by default. An image in an answer is a URL the model wrote, and // fetching it unasked is a request to a host nobody chose. return Custom ? : null; } if (token.kind === 'code') { // A `Text`, not `Typography.Code` — that one is a view around a text, and // a view inside a paragraph breaks the line before it and leaves a gap // where the sentence should have carried on. A background and a // monospace face are what make it read as code; they survive nesting, // and padding does not. // // No relative font size either. There is no `em` here to be relative to: // a value in one sends the style resolver looking for a parent size that // is itself expressed in `em`, and it does not come back. The inherited // size is the right one anyway — inline code is part of its sentence. return ( {token.value} ); } if (token.kind === 'link') { const href = token.href ?? ''; const openable = context.allowedLinkPrefixes.some((prefix) => href.startsWith(prefix)); return ( (context.onLinkPress ?? defaultOpen)(href) : undefined } > {token.value} ); } if (!token.bold && !token.italic && !token.strike) return token.value; return ( {token.value} ); }); } function marks(token: InlineToken): string { return cn( token.bold && 'font-semibold', token.italic && 'italic', token.strike && 'line-through' ); } function defaultOpen(href: string) { // Fire and forget: a link the platform declines to open is not worth an // unhandled rejection, and there is nothing useful to do about one. void Linking.openURL(href).catch(() => {}); }