import { memo, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react' import { Text, View } from 'react-native' import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' import { MobileNativeChatLongPressContent as Content } from './MobileNativeChatLongPressContent' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isSubagentGroupBlock, isTextBlock } from '../../../src/shared/native-chat-types' import { isRenderableSubagentGroup, withoutSubagentGroupTwins } from '../../../src/shared/native-chat-subagent-summary' import type { NativeChatBlock, NativeChatMessage, NativeChatToolCallBlock } from '../../../src/shared/native-chat-types' import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton' import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' import { MobileNativeChatGroupedThought, MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' import { mobileNativeChatRowId } from './mobile-native-chat-reading-place' import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow' import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays' import { MobileNativeChatUserMessageFold } from './MobileNativeChatUserMessageFold' import { MobileNativeChatMessageTime } from './MobileNativeChatMessageTime' import { MobileNativeChatAwaitingInputRow } from './MobileNativeChatAwaitingInputRow' import { MobileNativeChatResolutionReceipt } from './MobileNativeChatResolutionReceipt' import { nativeChatAskRunBlocks, nativeChatAskRunSubject } from '../../../src/shared/native-chat-ask-row' import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' import { MobileNativeChatSubagentGroup } from './MobileNativeChatSubagentGroup' import { ToolRun } from './MobileNativeChatToolRun' import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { styles } from './mobile-native-chat-message-styles' import { agentMessageAttribution } from './mobile-agent-message-attribution' import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context' import { useMobileNativeChatFullMessage } from './MobileNativeChatFullContent' import { MobileNativeChatPacedProse } from './MobileNativeChatPacedProse' import { Prose } from './MobileNativeChatProse' function MobileNativeChatMessageImpl({ message: rowMessage, rowKey, runOwner, mayStillGrow = false, previousTodoWrite, previousUpdatePlan, fontScale = 1, onOpenFile, turnStatus, turnStatusAbove = false, turnExpanded, turnKey, onToggleTurn, activeTurnIsWorking, trailingRun, workRun, joinsWorkRun = false, foldedAway = false, openReasoning, structuredActivityUi = false, reasoningIsLive = false, reasoningExpanded, onToggleReasoning, unsentNotice, receipt, awaiting, subagentGroupsOpen, onToggleSubagentGroup, userMessageOpen, onToggleUserMessage }: { message: NativeChatMessage /** The row's place on the page, kept while older messages join it; tags the row. */ rowKey?: string runOwner?: string /** The newest assistant row of a live turn with no prompt open: its last text may still grow. */ mayStillGrow?: boolean /** The last plan each task-list tool wrote before this row, so an update reads as its changes. */ previousTodoWrite?: NativeChatToolCallBlock previousUpdatePlan?: NativeChatToolCallBlock /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void /** This turn's status row, rendered under its opening user message. */ turnStatus?: NativeChatTurnStatus | null /** Render the status above the row: its turn has no user bubble of its own. */ turnStatusAbove?: boolean /** Whether the turn caret has disclosed this turn's activity. */ turnExpanded?: boolean /** Set only when this row's turn has settled and can disclose its activity. */ turnKey?: string /** Stable across renders; the row supplies its own key when tapped. */ onToggleTurn?: (turnKey: string) => void /** Session-level working state for this message's turn; gates the live tool row. */ activeTurnIsWorking?: boolean /** Whether this row is the live turn's frontier, the last where the agent spoke or acted. */ trailingRun?: boolean /** Set on the first row of a work run: every member, which this row draws as one run. */ workRun?: readonly NativeChatMessage[] /** A later member of a work run, which its first row draws. */ joinsWorkRun?: boolean /** Folded behind its settled turn's "Worked for" row. */ foldedAway?: boolean /** The reasoning disclosures the reader opened, for thoughts drawn inside the work run. */ openReasoning?: ReadonlySet /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean /** This open reasoning block is disclosed by the live activity line, so its row draws nothing. */ reasoningIsLive?: boolean /** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */ reasoningExpanded?: boolean onToggleReasoning?: (key: string) => void /** Why the host did not deliver this message, when it is drawn as not sent. */ unsentNotice?: string receipt?: NativeChatResolvedPrompt awaiting?: boolean /** The roster groups the reader opened, held by the transcript; only a roster row gets it. */ subagentGroupsOpen?: ReadonlySet onToggleSubagentGroup?: (groupId: string) => void userMessageOpen?: boolean onToggleUserMessage?: (messageId: string) => void }): React.JSX.Element { const { shown: message, showFull, recovery } = useMobileNativeChatFullMessage(rowMessage) // Another agent's message is set apart from the person's bubble, left-aligned and named. const attribution = agentMessageAttribution('Message from', message.from) const isUser = message.role === 'user' && attribution === null const isReasoning = message.role === 'reasoning' // 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: blocksOfTools } = splitNativeChatBlocks(message.blocks) const { asks, unansweredAsks, work: ownTools } = nativeChatAskRunBlocks(blocksOfTools) const { openActions: openSheet } = useContext(MobileNativeChatRowOverlaysContext) // A work run draws every member's calls as this row's one run, its thoughts among them. const run = useMemo(() => (workRun ? nativeChatWorkRunEntries(workRun) : null), [workRun]) const tools = run?.blocks ?? ownTools const asides = useMemo(() => { if (!run) { return undefined } const thought = (thoughtMessage: NativeChatMessage): React.JSX.Element => ( // Keyed by the thought, so it stays mounted as calls arrive after it. openSheet(thoughtMessage)} /> ) const before = new Map() for (const [block, thoughts] of run.thoughtsBefore) { before.set(block, thoughts.map(thought)) } return { before, after: run.thoughtsAfter.map(thought) } }, [run, fontScale, activeTurnIsWorking, openReasoning, onToggleReasoning, onOpenFile, openSheet]) // A roster draws as its group row, which replaces its frozen sentence. A thought heading a // run draws inside the run, so its words are not this row's prose. const drawnProse = isReasoning ? [] : withoutSubagentGroupTwins(prose) // The agent did not write a system row: its words draw as a notice or a faint aside. const isSystem = message.role === 'system' const showToolRun = tools.length > 0 const askPending = structuredActivityUi ? selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null : unansweredAsks.length > 0 const askRow = asks.length > 0 ? ( ) : null // A ref keeps the memoized Markdown context stable as the message streams. const pressedMessage = useRef(message) useLayoutEffect(() => { pressedMessage.current = message }, [message]) const openActions = useCallback(() => openSheet(pressedMessage.current), [openSheet]) const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions const renderVisual = useContext(MobileNativeChatVisualContext) ?? undefined // Structured replies grow in place: only the last block of the newest row may still be typing. const growingBlock = message.role === 'assistant' && mayStillGrow && activeTurnIsWorking === true ? message.blocks.at(-1) : undefined const statusRow = turnStatus ? ( onToggleTurn(turnKey) : undefined} /> ) : null // A receipt outlives the fold, as on desktop: it is the record of what the reader answered. if (joinsWorkRun || (foldedAway && !receipt)) { return <>{statusRow} } if (receipt) { return ( <> {turnStatusAbove ? statusRow : null} {turnStatusAbove ? null : statusRow} ) } // A run that opens on a thought draws as the run, the thought inside it. if (isReasoning && !workRun) { // The same text the live line's selector reads, so the two agree on whether there is any. const markdown = deriveNativeChatRowContent(message.blocks).markdown // Blank, or disclosed by the live activity line: nothing draws, not even an empty row. const draws = markdown.trim().length > 0 && !reasoningIsLive return ( <> {turnStatusAbove ? statusRow : null} {draws ? ( {showFull} ) : null} {turnStatusAbove ? null : statusRow} ) } const isChatTurn = message.role === 'user' || message.role === 'assistant' const copyText = isChatTurn ? nativeChatMessagePlainText(message) : '' const proseRow = (block: NativeChatBlock, index: number, text: string, revealing = false) => isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? ( ) : isSystem ? null : ( ) const proseRows = (blocks: NativeChatBlock[]) => blocks.map((block, index) => proseRow(block, index, isTextBlock(block) ? block.text : '')) return ( <> {/* A turn with no user bubble carries its bar above its first row. */} {turnStatusAbove ? statusRow : null} {attribution !== null ? ( {attribution} ) : null} {/* A roster is not tool activity, so it stays out of the settled-tools collapse. */} {isSystem ? ( ) : null} {isUser ? ( // As desktop: the pictures, then the words, folded by desktop's rule on the same text. <> {proseRows(drawnProse.filter(isImageRefBlock))} onToggleUserMessage?.(message.id)} recovery={recovery} status={showFull} > {proseRows(drawnProse.filter((block) => !isImageRefBlock(block)))} ) : ( {(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)} )} {isUser ? null : showFull} {askRow} {showToolRun ? ( ) : null} {unsentNotice ? {unsentNotice} : null} {/* Always shown, so the row never shifts when copy appears. */} {isChatTurn ? ( {copyText.length > 0 ? ( ) : null} ) : null} {turnStatusAbove ? null : statusRow} ) } export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)