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)