import { memo, useEffect, useRef, useState } from 'react' import { Image, Pressable, Text, View } from 'react-native' import * as Clipboard from 'expo-clipboard' import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { colors } from '../theme/mobile-theme' import { isImageRefBlock, isTextBlock, pairToolBlocks, splitNativeChatBlocks, type ToolPair } from './mobile-native-chat-blocks' import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' import { nativeChatMessageText } from './mobile-native-chat-message-text' import { createToolInputDisplay, summarizeToolRun, truncateToolDetail } from './mobile-native-chat-tool-summary' const MAX_VISIBLE_TOOL_PAIRS = 6 const MAX_TOOL_RUN_DIFF_ROWS = 240 function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { return ( {lines.map((line, i) => ( {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} {line.text} ))} ) } /** A single inline tool line — `▸ ToolName preview` — that expands in place to * show the call's diff/input or the result's body. Mirrors the reference design * where tool calls read as flat lines in the conversation, not boxed blocks. */ function ResultBody({ output, isError, diff }: { output: string isError?: boolean diff: DiffLine[] | null }): React.JSX.Element { if (diff) { return } return ( {truncateToolDetail(output)} ) } /** One request: a tool call and its result rendered together as a single * expandable line. `defaultExpanded` lets the group toggle open every line. */ function ToolLine({ pair, defaultExpanded, diffLineLimit, onOpenFile }: { pair: ToolPair defaultExpanded: boolean diffLineLimit: number onOpenFile?: (relativePath: string) => void }): React.JSX.Element { const [expanded, setExpanded] = useState(defaultExpanded) const { call, result } = pair const name = call ? call.name : 'Result' const inputDisplay = call ? createToolInputDisplay(call.input) : null const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? '' // Why: collapsed tool rows are the common path; defer bounded diff parsing // and detail formatting until the user asks to reveal the detail. const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true // The group toggle opens every line at once, bypassing the tap guard, so the // panel has to consult it too — else a detail-less row echoes its own label // under itself and no tap can dismiss it. const showDetail = hasDetail && expanded // A tool that targets a file (Read/Edit/Write…) renders its preview as a // tappable link that opens the file, independent of the line's expand tap. const filePath = inputDisplay?.filePath ?? null const openable = filePath !== null && onOpenFile !== undefined return ( hasDetail && setExpanded((v) => !v)} hitSlop={6} > {showDetail ? ( ) : ( )} {name} {preview ? ( onOpenFile!(filePath!) : undefined} suppressHighlighting={!openable} > {preview} ) : null} {showDetail ? ( {callDiff ? : null} {callDetail ? {callDetail} : null} {result ? ( ) : null} ) : null} ) } function Prose({ block, invert, fontScale, onOpenFile }: { block: NativeChatBlock invert?: boolean fontScale: number onOpenFile?: (relativePath: string) => void }): React.JSX.Element | null { if (isTextBlock(block)) { // Inverted (user) bubbles use a fixed dark-on-light text rather than the // markdown renderer's light-on-dark palette. if (invert) { return ( {block.text} ) } return ( ) } if (isImageRefBlock(block)) { // A local preview (composer echo) or real URL renders as a thumbnail; a bare // host path (not loadable on the device) falls back to a text placeholder. const uri = block.url ?? block.path if (isRenderableImageUri(uri)) { return ( ) } return ( 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} ) } return null } /** A run of a message's tool calls/results, collapsed to a one-line summary that * expands to the individual inline tool lines. `defaultExpanded` lets the global * toolbar toggle drive every run at once while still allowing per-run override. */ function ToolRun({ blocks, defaultExpanded, trailing, onOpenFile }: { blocks: NativeChatBlock[] defaultExpanded: boolean trailing?: React.ReactNode onOpenFile?: (relativePath: string) => void }): React.JSX.Element { const [open, setOpen] = useState(defaultExpanded) const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) let callCount = 0 for (const block of blocks) { if (block.type === 'tool-call') { callCount++ } } callCount ||= pairs.length const summary = summarizeToolRun(blocks) return ( setOpen((v) => !v)} hitSlop={6}> {open ? ( ) : ( )} {callCount}× {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} {trailing} {open ? ( {pairs.map((pair, i) => ( ))} {callCount > pairs.length ? ( … {callCount - pairs.length} more tool calls ) : null} ) : null} ) } /** Subtle top-right controls for an agent message: copy its prose, or scroll so * this message's top aligns to the top of the viewport. */ function AgentControls({ onCopy, onScrollToTop }: { onCopy: () => void onScrollToTop?: () => void }): React.JSX.Element { return ( [styles.controlButton, pressed && styles.controlPressed]} onPress={onCopy} hitSlop={8} accessibilityLabel="Copy message" > {onScrollToTop ? ( [styles.controlButton, pressed && styles.controlPressed]} onPress={onScrollToTop} hitSlop={8} accessibilityLabel="Scroll this message to top" > ) : null} ) } function MobileNativeChatMessageImpl({ message, queued, toolsExpanded = false, fontScale = 1, messageIndex, onScrollToMessage, onOpenFile }: { message: NativeChatMessage queued?: boolean toolsExpanded?: boolean /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number /** This message's index in the list, paired with onScrollToMessage. */ messageIndex?: number /** Ask the list to align this message's top to the top of the viewport. */ onScrollToMessage?: (index: number) => void onOpenFile?: (relativePath: string) => void }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isAgent = !isUser // Briefly tint the bubble to confirm a copy landed. const [copied, setCopied] = useState(false) const copyTimer = useRef | null>(null) useEffect( () => () => { if (copyTimer.current) { clearTimeout(copyTimer.current) } }, [] ) // Separate the agent's words from its tool activity: prose renders first, the // tool calls fold into a collapsible run beneath. The user's own messages get // an inverted (filled accent) bubble so they stand apart from agent prose. const { prose, tools } = splitNativeChatBlocks(message.blocks) const handleCopy = (): void => { const text = nativeChatMessageText(message.blocks) if (!text) { return } void Clipboard.setStringAsync(text) setCopied(true) if (copyTimer.current) { clearTimeout(copyTimer.current) } copyTimer.current = setTimeout(() => setCopied(false), 700) } // Copy + scroll-to-top, shown inline with the first tool call (or after the // prose when there are no tools). const controls = isAgent && !queued ? ( onScrollToMessage(messageIndex) : undefined } /> ) : null return ( {isUser && queued ? Queued : null} {prose.map((block, index) => ( ))} {tools.length > 0 ? ( ) : controls ? ( {controls} ) : null} ) } export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)