Files
orca/mobile/src/session/MobileNativeChatMessage.tsx
T
Brennan Benson 6c7a6ddd54 fix(native-chat): save every chat reply and tool output whole; bound only the copy sent to clients (#29982)
* fix(native-chat): save every chat reply and tool output whole; bound only the copy sent to clients

Orca cut Codex replies, every agent's reasoning, plans, prompts and all tool
output to a 16 KB head when it saved them, and threw the rest away. The
limit existed so a reconnecting client is not replayed a huge row, but it
was applied at save time, so every transport limit became permanent data
loss.

The journal now keeps what the agent said. One outbound form, applied to
every history page, live batch and catch-up, sends a client the same 16 KB
head of each tool output and diff it always got, and marks the item
`shortened`. A new capability-gated `agentSession.item` RPC returns the
whole item, and the desktop and phone show "Show full output" / "Show full
message" when the copy was cut. Items past half a page are shortened and
marked instead of replaced with "too large to show".

Two bounds stay, both named: a 4 MiB ceiling per text (head and tail),
applied where every provider's rows enter the write queue so one write can
never stall a chat, and a preview on Grok/OpenCode/OMP/Pi calls while they
run, whose closing row is built from the whole value the translator held.

* test(native-chat): an over-budget history item is shortened and marked, not replaced

* fix(native-chat): format, keep the sink and projection under the line limit, update method count and tests

* fix(chat): preserve complete writes and keep detail current

* test(chat): align final tail reservation with captured content

* Prototype source-batch journal ownership for Claude

* Pin the resolved iterator promise in ownership experiment

* Revert "Pin the resolved iterator promise in ownership experiment"

This reverts commit bdb49ab738.

* Revert "Prototype source-batch journal ownership for Claude"

This reverts commit e5ed387f0c.

* Preserve large received chat content while pacing input

* Verify received chat writes wait for journal persistence

* Close test journal databases before removing temporary roots

* Exercise item detail in the cross-version chat wire manifest

* Pin complete saved output and fitted client publication

* Exercise Pi exit retention with real journal write pressure

* Settle terminal web test unmount before DOM teardown

* Reuse existing readers and contract support in session tests

* Reuse measured chat item sizes in history pages

* fix(chat): derive page form type from reader contract

* fix(chat): open phone user messages while loading full text

* test(native-chat): reuse imperative context provider construction

* test(native-chat): format full-content disclosure fixture

* ci: preserve unit shard diagnostics after a timeout

* ci: retain unit shard output when tests time out

* ci: expose unit shard watchdog and output stages

* Revert "ci: expose unit shard watchdog and output stages"

This reverts commit 0b4991b144.

* Revert "ci: retain unit shard output when tests time out"

This reverts commit 87eec4c04f.

* Revert "ci: preserve unit shard diagnostics after a timeout"

This reverts commit 1888c656bb.

* test: verify complete Claude tool output without large failure diffs

* Simplify reply extraction in cross-version session test

* Restore full message recovery for grouped phone thoughts

* Target recovery button in grouped thought fixture

* test(native-chat): count agentSession.item alongside main's new structured method

* fix(chat): reuse strict session subscription schema

* test(chat): open Codex before checking received content
2026-10-20 11:13:48 -07:00

388 lines
16 KiB
TypeScript

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 './MobileNativeChatScrollHold'
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,
toolsExpanded = 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
toolsExpanded?: 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. The Tools toggle still shows it, or that
* toggle would do nothing on every settled turn. */
foldedAway?: boolean
/** The reasoning disclosures the reader opened, for thoughts drawn inside the work run. */
openReasoning?: ReadonlySet<string>
/** 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 {
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.
<MobileNativeChatGroupedThought
key={`thought:${thoughtMessage.id}`}
message={thoughtMessage}
fontScale={fontScale}
live={activeTurnIsWorking === true}
expanded={openReasoning?.has(nativeChatReasoningDisclosureKey(thoughtMessage.id)) === true}
onToggle={onToggleReasoning}
onOpenFile={onOpenFile}
// Its own actions, as a standalone thought's: the run's row is another message.
onLongPress={INLINE_TEXT_SELECTION ? undefined : () => openSheet(thoughtMessage)}
/>
)
const before = new Map<NativeChatBlock, React.JSX.Element[]>()
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 ? (
<MobileNativeChatAwaitingInputRow
subject={nativeChatAskRunSubject(asks)}
pending={askPending}
fontScale={fontScale}
listsQuestions={false}
/>
) : 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 ? (
<MobileNativeChatTurnStatus
startedAt={turnStatus.startedAt}
workedSeconds={turnStatus.workedSeconds}
verdict={turnStatus.verdict}
expanded={turnExpanded ?? false}
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
/>
) : null
// A receipt outlives the fold, as on desktop: it is the record of what the reader answered.
if (joinsWorkRun || (foldedAway && !toolsExpanded && !receipt)) {
return <>{statusRow}</>
}
if (receipt) {
return (
<>
{turnStatusAbove ? statusRow : null}
<View style={styles.row} testID={mobileNativeChatRowId(rowKey ?? message.id)}>
<MobileNativeChatResolutionReceipt
body={receipt}
awaiting={awaiting}
fontScale={fontScale}
/>
</View>
{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 ? (
<View style={styles.row} testID={mobileNativeChatRowId(rowKey ?? message.id)}>
<MobileNativeChatReasoningRow
message={message}
markdown={markdown}
fontScale={fontScale}
live={activeTurnIsWorking === true}
expanded={reasoningExpanded}
onToggle={onToggleReasoning}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
{showFull}
</View>
) : 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) ? (
<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]}
testID={mobileNativeChatRowId(rowKey ?? message.id)}
>
<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)}
recovery={recovery}
status={showFull}
>
{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>
)}
{isUser ? null : showFull}
{askRow}
{showToolRun ? (
<ToolRun
blocks={tools}
// Opening the turn reveals its runs folded, as on desktop; the Tools toggle is the
// default every run and line reads, and a reader's own choice is kept per default.
defaultExpanded={toolsExpanded}
expandChildren={toolsExpanded}
disclosureId={runOwner ?? rowKey ?? message.id}
asides={asides}
activeTurnIsWorking={structuredActivityUi ? activeTurnIsWorking : undefined}
trailing={trailingRun}
awaitingAnswer={askPending}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
) : null}
</Content>
{unsentNotice ? <Text style={styles.unsentNotice}>{unsentNotice}</Text> : null}
{/* Always shown, so the row never shifts when copy appears. */}
{isChatTurn ? (
<View style={styles.meta}>
{copyText.length > 0 ? (
<MobileSourceCopyButton
key={message.id}
text={copyText}
accessibilityLabel="Copy message"
/>
) : null}
<MobileNativeChatMessageTime timestamp={message.timestamp} fontScale={fontScale} />
</View>
) : null}
</View>
{turnStatusAbove ? null : statusRow}
</>
)
}
export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)