Files
orca/mobile/src/session/MobileNativeChatToolLine.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

268 lines
9.9 KiB
TypeScript

import { useMemo } from 'react'
import { Pressable, Text, View } from 'react-native'
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
import { ChevronRight } from 'lucide-react-native'
import { agentJournalToolCallLifecycle } from '../../../src/shared/agent-journal-tool-call-lifecycle'
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
import type { NativeChatDiffLine } from '../../../src/shared/native-chat-diff'
import {
formatToolDuration,
mcpToolIdentity,
toolWebSearchResults,
type NativeChatWebSearchResult
} from '../../../src/shared/native-chat-tool-identity'
import { nativeChatToolIconName } from '../../../src/shared/native-chat-tool-icon'
import {
NATIVE_CHAT_TOOL_ROW_VERB_COPY,
nativeChatToolLineLabel
} from '../../../src/shared/native-chat-tool-line-label'
import {
isToolCallBlock,
type NativeChatToolCallBlock,
type NativeChatToolResultBlock
} from '../../../src/shared/native-chat-types'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { openExternalLink } from '../platform/external-link'
import { colors } from '../theme/mobile-theme'
import { highlightMobileCode } from './mobile-file-syntax'
import { MobileNativeChatToolGlyph } from './MobileNativeChatToolIcon'
import { MobileNativeChatToolOutput } from './MobileNativeChatFullContent'
import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail'
import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store'
import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles'
const RESULT_LABEL = 'Result'
const NO_SEARCH_RESULTS: NativeChatWebSearchResult[] = []
/** Desktop parity: `NativeChatSearchResults`. The search's hits as links. */
function SearchResults({
results,
fontScale
}: {
results: NativeChatWebSearchResult[]
fontScale: number
}): React.JSX.Element {
const type = toolType(fontScale).meta
return (
<View style={[styles.codeBox, styles.searchResults]}>
{results.map((hit) => (
<Text
key={hit.url}
style={[styles.codeText, styles.searchHit, type]}
numberOfLines={1}
onPress={() => openExternalLink(hit.url)}
accessibilityRole="link"
>
{hit.title}
{hit.title !== hit.url ? <Text style={styles.searchUrl}>{` ${hit.url}`}</Text> : null}
</Text>
))}
</View>
)
}
function DiffView({ lines, fontScale }: { lines: NativeChatDiffLine[]; fontScale: number }) {
const type = toolType(fontScale).code
return (
<View style={styles.codeBox}>
{lines.map((line, index) => (
<Text
key={index}
style={[
styles.codeText,
type,
line.kind === 'add' && styles.diffAdd,
line.kind === 'del' && styles.diffDel,
line.kind === 'meta' && styles.diffMeta
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</Text>
))}
</View>
)
}
/** Desktop parity: `NativeChatToolLine`'s `ToolLineDetail`. Its own component so a
* row diffs and highlights only while it is open. */
function ToolLineDetail({
call,
body,
fullCommand,
commandLanguage,
results,
fontScale,
disclosureKey,
memberId
}: {
call: NativeChatToolCallBlock | undefined
body: NativeChatToolResultBlock | undefined
fullCommand: string | null
commandLanguage: string | null
results: NativeChatWebSearchResult[]
fontScale: number
disclosureKey?: string
memberId?: string
}): React.JSX.Element {
const diff = call
? diffFromToolCall(call.name, call.input)
: body
? diffFromText(body.output)
: null
const command = useMemo(
() =>
!diff && fullCommand && commandLanguage
? highlightMobileCode(fullCommand, commandLanguage).segments
: null,
[diff, fullCommand, commandLanguage]
)
const type = toolType(fontScale).code
const onLayout = useMobileNativeChatOpenedDetail()
return (
<View style={styles.detail} onLayout={onLayout}>
{results.length > 0 ? <SearchResults results={results} fontScale={fontScale} /> : null}
{diff ? <DiffView lines={diff} fontScale={fontScale} /> : null}
{!diff && fullCommand ? (
<MobileNativeChatOwnedScrollView
rootIdentity={`${memberId ?? call?.callId ?? 'detail'}:command`}
positionKey={`${disclosureKey ?? call?.callId ?? 'detail'}:command`}
style={[styles.codeBox, styles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text selectable={INLINE_TEXT_SELECTION} style={[styles.codeText, type]}>
{command ? <MobileSyntaxSegments segments={command} /> : fullCommand}
</Text>
</MobileNativeChatOwnedScrollView>
) : null}
{body ? (
<MobileNativeChatToolOutput
body={body}
type={type}
rootIdentity={`${memberId ?? body.callId ?? 'detail'}:output`}
positionKey={`${disclosureKey ?? body.callId ?? 'detail'}:output`}
/>
) : null}
</View>
)
}
/** Desktop parity: `NativeChatToolLine`. A tool sentence with its output behind its
* own disclosure; an orphan result draws as a `Result` row. */
export function MobileNativeChatToolLine({
memberId,
block,
result,
defaultExpanded,
disclosureKey,
fontScale,
onOpenFile
}: {
/** Tags the line for the page's place-hold. */
memberId?: string
block: NativeChatToolCallBlock | NativeChatToolResultBlock
/** This call's output, when the run paired one to it. */
result?: NativeChatToolResultBlock
defaultExpanded: boolean
/** Identity this line's open state is remembered under while it is unmounted. */
disclosureKey?: string
fontScale: number
onOpenFile?: (path: string) => void
}): React.JSX.Element {
const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, defaultExpanded)
const call = isToolCallBlock(block) ? block : undefined
const label = call ? nativeChatToolLineLabel(call, result) : null
const body = call ? result : block.type === 'tool-result' ? block : undefined
const fullCommand = label?.commandDetail ?? null
// A call the reader stopped was cut short, not failed.
const lifecycle = call ? agentJournalToolCallLifecycle(call) : null
const failed = lifecycle === 'failed' || (lifecycle !== 'interrupted' && result?.isError === true)
const verb = label?.verb ? NATIVE_CHAT_TOOL_ROW_VERB_COPY[label.verb] : null
// The run's header already says these ran; the row is the command itself.
const commandAlone = label?.command === true && verb !== null && label.target.length > 0
const mcp = call ? mcpToolIdentity(call.name, call.mcpIdentity) : null
const name = call ? (mcp ? `${mcp.server} / ${mcp.tool}` : call.name) : RESULT_LABEL
const target = label ? label.target : (body?.output.split('\n')[0]?.slice(0, 80) ?? '')
const results = useMemo(
() => (call ? toolWebSearchResults(call.webSearchResults) : NO_SEARCH_RESULTS),
[call]
)
// An edit that has not landed has no output yet, but the change it proposes is worth opening.
const hasDetail =
body !== undefined ||
fullCommand !== null ||
results.length > 0 ||
(call !== undefined && diffFromToolCall(call.name, call.input) !== null)
const filePath = label?.filePath ?? null
const type = toolType(fontScale)
const iconSize = 15 * fontScale
const exitCode = call && Number.isSafeInteger(call.exitCode) ? call.exitCode : undefined
const duration = call ? formatToolDuration(call.durationMs) : null
return (
<View testID={memberId}>
<Pressable
style={styles.line}
onPress={() => hasDetail && setExpanded(!expanded)}
disabled={!hasDetail}
accessibilityRole="button"
accessibilityState={hasDetail ? { expanded } : undefined}
>
<View style={[styles.iconSlot, { width: iconSize }]}>
{call ? (
<MobileNativeChatToolGlyph
name={nativeChatToolIconName(call.name, call.mcpIdentity)}
size={iconSize}
color={failed ? colors.statusRed : colors.textMuted}
/>
) : null}
</View>
{commandAlone ? null : (
<Text style={[styles.lineWord, type.row, !verb && styles.lineName]} numberOfLines={1}>
{verb ?? name}
</Text>
)}
{target ? (
<Text
style={[styles.lineTarget, type.row, filePath && onOpenFile && styles.lineLink]}
numberOfLines={1}
onPress={filePath && onOpenFile ? () => onOpenFile(filePath) : undefined}
suppressHighlighting={!(filePath && onOpenFile)}
accessibilityRole={filePath && onOpenFile ? 'link' : undefined}
>
{target}
</Text>
) : null}
{/* Held at the row's right edge, so the carets of a run line up in one column. */}
<View style={styles.lineTrailing}>
{exitCode !== undefined ? (
<Text style={[styles.lineMeta, type.meta, exitCode !== 0 && styles.errorText]}>
{`exit ${exitCode}`}
</Text>
) : null}
{duration ? <Text style={[styles.lineMeta, type.meta]}>{duration}</Text> : null}
{hasDetail ? (
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</View>
</Pressable>
{hasDetail && expanded ? (
<ToolLineDetail
memberId={memberId}
disclosureKey={disclosureKey}
call={call}
body={body}
fullCommand={fullCommand}
commandLanguage={label?.commandLanguage ?? null}
results={results}
fontScale={fontScale}
/>
) : null}
</View>
)
}