Files
orca/mobile/src/session/MobileNativeChatMessage.tsx
T
Brennan Benson b61b9da778 fix(mobile): a streaming reply reveals word by word, like the desktop (#29148)
* fix(mobile): a streaming reply reveals word by word, like the desktop

The phone drew each streamed chunk as it landed, so a reply arrived in a few
jumps. Move the desktop's paced reveal into src/shared and use it on both
surfaces: the phone paces the growing block of the newest reply, remembers
progress per chat list so a recycled row resumes, and draws everything at once
with reduced motion. The terminal-backed chat's live row now counts as growing.

* fix(mobile): hold the reply reveals in the chat overlay

The list's turn resolver must not scan terminal-backed transcripts, and the
overlay outlives the list across chat and terminal toggles, so it owns the
reveal memory and the reduced-motion read. Also fix the moved hook's import
in the onboarding route, and mock the OS read where tests render the overlay.

* test(mobile): mock the message row's other children in the reveal test

Their real modules reach React Native's Flow sources, which Bun cannot load;
the other message row tests mock them the same way.

* style: format the desktop paced-text hook

* Keep phone reply reveal progress owned by its message

* Add braces around phone reveal test assertion

* Start phone prose streaming only at a text block

* Find the mocked phone tool row with a node predicate

* refactor(mobile): move chat prose rendering into its own file

Keeps MobileNativeChatMessage.tsx under the 400-line limit after the main merge.
2026-10-16 18:51:18 -07:00

323 lines
13 KiB
TypeScript

import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo, useCallback, useContext, useState } from 'react'
import { Text as NativeText, View } from 'react-native'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
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 } 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 { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow'
import { MobileNativeChatUserMessageFold } from './MobileNativeChatUserMessageFold'
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 { MobileNativeChatPacedProse } from './MobileNativeChatPacedProse'
import { Prose } from './MobileNativeChatProse'
function MobileNativeChatMessageImpl({
message,
mayStillGrow = false,
toolsExpanded = false,
fontScale = 1,
onOpenFile,
turnStatus,
turnStatusAbove = false,
turnExpanded,
turnKey,
onToggleTurn,
activeTurnIsWorking,
structuredActivityUi = false,
reasoningIsLive = false,
reasoningExpanded,
onToggleReasoning,
unsentNotice,
receipt,
awaiting,
subagentGroupsOpen,
onToggleSubagentGroup,
userMessageOpen,
onToggleUserMessage
}: {
message: NativeChatMessage
/** The newest assistant row of a live turn with no prompt open: its last text may still grow. */
mayStillGrow?: boolean
toolsExpanded?: boolean
/** 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
/** 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<string>
onToggleSubagentGroup?: (groupId: string) => void
userMessageOpen?: boolean
onToggleUserMessage?: (messageId: string) => void
}): React.JSX.Element {
// 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: tools } = nativeChatAskRunBlocks(blocksOfTools)
// A roster draws as its group row, which replaces its frozen sentence.
const drawnProse = 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 activeCall = structuredActivityUi
? selectActiveToolCall(tools, { activeTurnIsWorking })
: null
// A completed turn's activity belongs behind the turn-status caret. Leaving the
// grouped row visible made a failed child command read as a failed response.
// The composer's global Tools toggle still overrides this, or it would silently
// do nothing on every settled turn.
const settledToolsHidden =
structuredActivityUi &&
activeCall == null &&
activeTurnIsWorking === false &&
!turnExpanded &&
!toolsExpanded
const showToolRun = tools.length > 0 && !settledToolsHidden
const askPending = structuredActivityUi
? selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null
: unansweredAsks.length > 0
const askRow =
asks.length > 0 && !settledToolsHidden ? (
<MobileNativeChatAwaitingInputRow
subject={nativeChatAskRunSubject(asks)}
pending={askPending}
fontScale={fontScale}
listsQuestions={false}
/>
) : null
// Mount selection UI only for the message being copied.
const [actionsOpen, setActionsOpen] = useState(false)
// Keep the memoized Markdown context stable as the message streams.
const openActions = useCallback(() => setActionsOpen(true), [])
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 ? (
<MobileNativeChatTurnStatus
startedAt={turnStatus.startedAt}
workedSeconds={turnStatus.workedSeconds}
verdict={turnStatus.verdict}
expanded={turnExpanded ?? false}
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
/>
) : null
if (receipt) {
return (
<>
{turnStatusAbove ? statusRow : null}
<View style={styles.row}>
<MobileNativeChatResolutionReceipt
body={receipt}
awaiting={awaiting}
fontScale={fontScale}
/>
</View>
{turnStatusAbove ? null : statusRow}
</>
)
}
if (isReasoning) {
// 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 ? (
<View style={styles.row}>
<MobileNativeChatReasoningRow
message={message}
markdown={markdown}
fontScale={fontScale}
live={activeTurnIsWorking === true}
expanded={reasoningExpanded}
onToggle={onToggleReasoning}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
</View>
) : null}
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
}
// Always shown, so the row never shifts when copy appears.
const copyText =
message.role === 'user' || message.role === 'assistant'
? nativeChatMessagePlainText(message)
: ''
const proseRow = (block: NativeChatBlock, index: number, text: string, revealing = false) =>
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? (
<MobileNativeChatSubagentGroup
key={`subagent-group:${block.groupId}`}
block={block}
open={subagentGroupsOpen?.has(block.groupId) === true}
onToggle={onToggleSubagentGroup}
/>
) : isSystem ? null : (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
renderVisual={message.role === 'assistant' ? renderVisual : undefined}
text={text}
growing={block === growingBlock}
revealing={revealing}
/>
)
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}
<View style={[styles.row, isUser && styles.rowUser]}>
<Content
onLongPress={onLongPress}
style={[
styles.content,
isUser && styles.userBubble,
attribution !== null && styles.agentMessage
]}
>
{attribution !== null ? (
<Text selectable={INLINE_TEXT_SELECTION} style={styles.agentAttribution}>
{attribution}
</Text>
) : null}
{/* A roster is not tool activity, so it stays out of the settled-tools collapse. */}
{isSystem ? (
<MobileNativeChatNoticeRow
blocks={drawnProse}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
) : null}
{isUser ? (
// As desktop: the pictures, then the words, folded by desktop's rule on the same text.
<>
{proseRows(drawnProse.filter(isImageRefBlock))}
<MobileNativeChatUserMessageFold
text={deriveNativeChatRowContent(message.blocks).markdown}
fontScale={fontScale}
open={userMessageOpen === true}
onToggle={() => onToggleUserMessage?.(message.id)}
>
{proseRows(drawnProse.filter((block) => !isImageRefBlock(block)))}
</MobileNativeChatUserMessageFold>
</>
) : (
<MobileNativeChatPacedProse
rowKey={message.id}
blocks={drawnProse}
growingBlock={growingBlock}
paced={message.role === 'assistant'}
>
{(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)}
</MobileNativeChatPacedProse>
)}
{askRow}
{showToolRun ? (
<ToolRun
// Why: a global toggle intentionally resets all per-run/per-line
// overrides in one remount, avoiding an effect-driven second render.
key={`${toolsExpanded ? 'expanded' : 'collapsed'}:${turnExpanded ? 'turn' : 'flat'}`}
blocks={tools}
defaultExpanded={turnExpanded || toolsExpanded}
expandChildren={turnExpanded ? false : toolsExpanded}
activeCall={activeCall}
onOpenFile={onOpenFile}
/>
) : null}
</Content>
{unsentNotice ? <NativeText style={styles.unsentNotice}>{unsentNotice}</NativeText> : null}
{copyText.length > 0 ? (
<MobileSourceCopyButton
key={message.id}
text={copyText}
accessibilityLabel="Copy message"
/>
) : null}
</View>
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
}
export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)