import { type ReactNode, useState } from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import type { StreamedReply } from './useReplyStream.ts' type Part = Record & { $type: string } type Blob = { ref: { $link: string }; mimeType: string } export type MessageViewProps = { id?: string record: Record /** Text and reasoning streamed so far for a pending reply. */ streaming?: StreamedReply blobUrl: (cid: string, mimeType?: string) => string siblings?: { index: number; count: number; onPick: (index: number) => void } actions?: ReactNode } const kind = (part: Part) => part.$type.split('#')[1] /** An image from model output, loaded only on click, since loading it could leak the chat through its URL. */ function MarkdownImage({ src, alt }: { src: string; alt?: string }) { const [shown, setShown] = useState(false) if (shown) return {alt return ( {alt && <>{alt} } {src}{' '} ) } const markdownComponents: Components = { a: ({ node: _node, ...props }) => , img: ({ src, alt }) => typeof src === 'string' && src ? : null, } /** Assistant text as Markdown. Raw HTML in it is not rendered. */ function Markdown({ text }: { text: string }) { return (
{text}
) } function Text({ text, role }: { text: string; role: unknown }) { return role === 'assistant' ? :

{text}

} function PartView({ part, role, blobUrl, }: { part: Part role: unknown blobUrl: MessageViewProps['blobUrl'] }) { switch (kind(part)) { case 'textPart': return case 'reasoningPart': return (
Reasoning

{(part.text as string | undefined) ?? ''}

) case 'toolCallPart': return (
Tool call: {part.tool as string}
{part.input as string}
) case 'toolResultPart': return (
{part.isError ? 'Tool error' : 'Tool result'}
{part.output as string}
) case 'sourcePart': return (
{(part.title as string | undefined) ?? (part.url as string)} ) case 'imagePart': { const image = part.image as Blob return ( {(part.alt ) } case 'filePart': { const file = part.file as Blob return ( {(part.name as string | undefined) ?? 'Attached file'} ) } default: // Skip part types this view doesn't know about. return null } } /** One message: its parts, its state, sibling controls, and actions. */ export function MessageView({ id, record, streaming, blobUrl, siblings, actions, }: MessageViewProps) { const content = record.content as { $type: string; parts?: Part[] } const encrypted = content.$type.endsWith('#encryptedContent') const parts = content.parts ?? [] const sources = parts.filter((part) => kind(part) === 'sourcePart') return (
{record.role === 'user' ? 'You' : 'Assistant'} {siblings && siblings.count > 1 && ( {siblings.index + 1} / {siblings.count} )}
{parts .filter((part) => kind(part) !== 'sourcePart') .map((part, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: a record's parts keep their order ))} {streaming !== undefined && record.status === 'pending' && ( <> {streaming.reasoning && (
Reasoning

{streaming.reasoning}

)} {streaming.text && } )} {sources.length > 0 && (
    {sources.map((part) => (
  • ))}
)} {encrypted &&

This message is encrypted.

} {record.status === 'pending' && streaming === undefined &&

Thinking...

} {record.status === 'error' && (

Error{record.error ? `: ${record.error as string}` : ''}

)} {record.status === 'cancelled' &&

Stopped.

} {actions && }
) }