Files
orca/mobile/src/session/MobileNativeChatToolLine.tsx
T
b0c55557f0 Keep phone chat history in one window while preserving reading position (#29271)
* fix(mobile): phone page loads older history only from the Load earlier tap

On the phone page a scroll near the top paged in older history, often while iOS
was still bouncing the list past the top. The place-hold then wrote scrollTop
while WebKit owned the scroll, and the list stopped scrolling until the reader
left the chat. The page now loads older history only from the existing Load
earlier control (the native app keeps its drag paging), and the place-hold
never writes while the list is bounced past either end.

* fix(mobile): a reconnect no longer drops the older page the reader asked for

A replayed snapshot cancelled any in-flight "Load earlier messages" page, even
when the replay merged onto the same window and cursor, and every other lost
page (host refusal, error reply, transport drop) was swallowed, so the control
went back to its label as if nothing had been tried.

A page now lands unless its window was replaced or the cursor it was sent from
changed. A page that is lost shows one line under the control,
"Couldn't load earlier messages. Tap to try again.", cleared by the next tap or
a page that lands.

* fix(mobile): keep the failed-page copy out of raw JSX text

* fix(mobile): name the failed-page copy

* fix(mobile): clear the failed-page line when the window is replaced

* docs(mobile): the older-page read's comment names the retry line a refusal now shows

* feat(mobile): one older-page loader that owns the spinner, the budget and the outcome

* fix(mobile): terminal-backed chats read older pages through the shared loader, bounded to one tap's budget

* fix(mobile): agent-session chats read older pages through the shared loader

A failed page now shows the retry line instead of an invisible chat error,
history that can never be read ends paging, and one tap shares one budget.

* fix(mobile): the Load earlier control renders the loader's outcome for both chat types

A retryable failure keeps the button with the retry line; history that can never
be read swaps the button for one line saying so; scrolling at the top no longer
re-fires a failed read.

* style(mobile): format the read-back guard

* test(mobile): a failed agent-session older page keeps the chat readable, retries on a tap, and ends at unreadable history

* test(mobile): the older-page read now records its per-tap budget in the page goldens

Hand-updated: only the nativeChat.readSession options change (absent -> 15 s
budget spanning connect); every value is re-hashed and re-keyed as the recorder does.

* fix(mobile): every history reset ends an agent-session tap quietly, as the subscription's reload replaces the window

Only a final read refusal (damaged history, or saved by a newer Orca) ends a tap at
'Earlier messages can't be loaded.'; the test now drives that reachable path.

* fix(mobile): history that can't be loaded stays a line until the window is replaced

* fix(mobile): a page row keeps its key when older messages join it

A folded row's id is its first message's, so an older page that brings the
start of a tool run (or an older sibling of a roster) replaced the displayed
row: the list remounted it and the page's history split lost its first row.
Rows now carry a presentation key that stays while any of their messages
draw, handed on from the last committed render by the raw message ids each
row draws, and a vanished key resolves to the row that took its messages.

* fix(mobile): the page holds a parked reader's message, and leaves a following one to the follow owner

The place-hold fired on the first drawn row's id, which a kept row key no
longer changes, and held the lowest row on screen even while the reader
followed the end, so at the 2,000 cap it wrote against the follow owner.
It now fires when the source window's first message changes (an older page,
a cap trim), only for a reader who is not following, and puts back the
first tagged row on screen, else the nearest row the update kept, through
the row keys. iOS bounce still gets no write.

* fix(mobile): an open tool run keeps the reader's lines when older calls join it

With the run's row kept, older calls joining its front pushed the lines the
reader was on behind Show more. The reveal now counts the lines added above
its first line apart from what Show more asked for, so those lines stay
shown, each diff keeps its share, and Show less only undoes Show more. Lines
and the run are tagged, so the page's hold puts the line being read back
where it was. The flow test opens a chat inside a silent run, loads the rest
from an old host's cursorless reply, then lands a reply and a send.

* fix(mobile): an open tool run reads its first line from the pairs it already drew

Pairing the run again for its first line on every render broke the bounded
pairing a 2,049-call run relies on (one pairing per reveal). The flow test's
icon mock names every icon the run reads.

* fix(mobile): a cap trim that splits the top run leaves the run its key

Row-key hand-on walked the current rows oldest first, so when a trim left a
run's first result alone above it, that orphan took the run's key and the open
run remounted closed. Walk newest row first so the run keeps it.

* fix(mobile): a row's key belongs to the messages it is made of, not to the results it shows

A cap trim that split the top run gave the run's key to whichever split-off result row a
position-ordered scan met first: the result above it for calls made one at a time, the one
below it for parallel calls. The run then remounted closed and the reader's line went.

Each row is now identified by its own sources (a run by its assistant and call messages, a
roster by its group, a lone result row by its results) and keeps the key of the one previous
row it continues; results are content only. A key no row kept resolves to the row that took
in what it drew. If identity ever came from two rows, neither inherits.

* fix(mobile): the page holds the first part of what the reader saw that is still the same part

The hold took a line only from the first row on screen and otherwise trusted
that row's container. A result row absorbed by its run, a roster the fold moved
to an older sibling, or a trimmed line then moved the reader. It now lists the
tool lines on screen across every row (a run header only when no line shows,
a row top only when neither), and puts back the first one still drawn: a run
header only in the row that kept its key, a row top only while the same
message places that row. One write, skipped under half a pixel.

* test(mobile): pin the run-header rule in the reading-place flow; fold the hold's last cases into it

An open run whose header is above the screen holds nothing: with only its
Show more on screen, the rows after it keep their height when older calls
join. Removing the header-on-screen check moves them 840 px. The collapsed
case now checks the same rows. The iOS bounce and native cases move into
the flow test over its page model, so the hold's separate fake scroller
goes.

* fix(mobile): the page's follow owner pins to the live end before paint when the transcript grows

RN Web reports content size a task after layout, so with no browser scroll
anchoring a following reader saw one frame far above the end after Load earlier.
One ResizeObserver over the page scroller and its content, bound to the list's
mount, calls the same pin, which reads the live end and writes only when short of it.

* test(mobile): one page follow flow pins growth, the late size report and layout to the live end

The page's follow tests fold into one flow so the view test stays within its
line limit; the place-hold reads the follow owner through one prop bag so the
view does too.

* fix(mobile): only the page asks the list for its scrollable node

The native list never needs the resize observer, so it shouldn't reach for
the page-only node at attach.

* fix(mobile): a following reader on the page keeps the end as the browser's scroll anchor

At the 2,000-message cap a trim plus an append of the same height changes no size, so the
follow owner hears nothing, and Chromium's scroll anchoring keeps a visible message still,
scrolling the reader one message off the end each time. While the reader follows, the page's
scroller now takes the transcript out of anchoring and leaves a 1 px end marker as the anchor.
Parked readers and native lists are unchanged.

* test(mobile): move the cap-anchoring flow into its own view test file

MobileNativeChatView.test.ts is at the 800-line limit for test files.

* fix(mobile): the cap-anchoring test passes every required view prop; the style augmentation names why it is an interface

* fix(mobile): type the page's anchoring style locally instead of augmenting ViewStyle

* style(mobile): wrap the page scroller's styles

* test(mobile): the cap-anchoring contract joins the page describe, and one scroll-event helper replaces the copied event literals

* fix(mobile): a chat switch on the page opens the next chat at its newest message

The chat view stays mounted across chats, so a reader parked in one chat left
the follow owner parked for the next. On a scope change the page now resumes
following and pins to the scroller's live end before paint; the place-hold
drops what it held for the previous chat. Native keeps its current switch
behavior.

* fix(mobile): the phone page draws only the messages near the screen, in one list

On the page, RN Web's list decided which rows were in the document by index and
drew them in batches after paint: a chat opened climbing through its history
before reaching the newest message, and rows paged in above the reader were
drawn in the list's header instead, a second path where iOS stopped finger
scrolling at the seam.

The page now draws the transcript in one keyed flow: Load earlier, a top
spacer, the rows near the screen, a bottom spacer, then the live status and
waiting messages. Which rows are drawn is named by row key, so a page landing
or a cap trim never changes it. Rows drawn above the reader take their height
out of the top spacer, so nothing moves and nothing is written while the reader
touches or scrolls; a correction the spacer can't take (the place-hold's, or
making room above) is written once the reader is at rest. The follow owner
still owns the end and pins at commit. Native keeps its FlatList and props.

* refactor(mobile): the page window's coverage decision is its own pure module; the view reads the follow owner through one handle

* fix(mobile): rows arriving in a chat the page drew empty open at its end, not all at once

* fix(mobile): the page window draws from every scroll event, not RN Web's throttled report

* style(mobile): one-line comments in the page window

* refactor(mobile): the chat view reads the follow owner through one handle, keeping the file under its line cap

* fix(mobile): the page's spacer holds only measured rows, and older pages land once the reader stops

The phone page's windowed list sized its top spacer from estimated row heights. On iPhone a scroll
write stops a fling, so the estimate error could only be removed at rest: reaching the top showed
thousands of pixels of empty space, and an older page landing mid-fling grew a row above the reader
and moved what they were reading.

Now the spacer stands only for rows measured for how they draw now; rows never measured are left out
and measured in place only while following or once the reader has stopped (no touch, no scroll of
theirs for 150 ms). A page that arrives while the reader scrolls waits for that moment. Rows report
their heights through the page's one ResizeObserver, coverage is arithmetic over those heights, and
one at-rest rule puts the row the reader was reading back when anything above it changed size.
Unchanged rows keep their objects across a re-fold, so a height stays valid until its row changes.

* refactor(mobile): the page window's row registry, height scans and sync render in their own modules, under the line caps

* fix(mobile): an opened tool run, its lines and Show more stay as the reader left them when the page window unmounts the row

The phone page now draws only rows near the screen, so a row the reader scrolled past unmounts. Its
tool run's open state, each line's open state and Show more lived in the row's own state and reset on
remount, closing what the reader opened and drawing the row at a height its measurement no longer
matched. They now live in one per-chat map the page list provides (the desktop transcript's
mechanism); rows rendered elsewhere keep their own state as before.

* style(mobile): format

* fix(mobile): quality rules: explicit listener cleanup, no side effects in a state updater, kept disclosures scoped by a keyed provider

* fix(mobile): the page window moves only on measured rows, and never off the end while following

At 4x CPU the end pin's scroll event reached the coverage pass before the observer had measured the
drawn rows, so they were summed at the first-guess height, read as above the screen, and the window
shrank to the last row; the browser's clamp then looked like the reader scrolling up and a long chat
opened at its oldest message. Coverage now waits until every drawn row has a measured height (the
observer reports them before paint), and a following reader's window always reaches the end.

* style(mobile): format

* fix(mobile): the reading place is taken only from the reader's scrolls, so Load earlier at the top keeps it

A write's scroll event can arrive after an older page's rows were drawn above and before the correction; re-taking the place and covering then kept the pushed position.

* fix(mobile): only the reader's touch, wheel, pointer or key ends following on the page

Scroll to latest after a parked turn could land at the top: the window drew the end, the document shrank and regrew, and a scroll event iOS delivered after rows grew above read as the reader moving up, so following ended and the window drew where the scroll had been clamped. A move up now ends following only within a second of the reader's input on the list.

* fix(mobile): a scroll the browser makes when the page shrinks is not the reader's, so Collapse keeps the place

Collapsing tools while parked shrinks the rows above as they are re-measured; the browser clamps the scroll, and its event (delivered before the resize callback) re-took the reading place with nothing on screen and moved the window, so the reader ended at the bottom. A scroll with no input of the reader's and no scroll of theirs just before is now layout's.

* fix(mobile): the page decides once whether a scroll was the reader's, from what explains it

Three bugs today had one cause: a scroll the reader didn't make was taken as theirs (a write's late event, a clamp after the document shrank, a scroll delivered after rows changed). A scroll is now the reader's only when nothing else explains the move: not the page's write, not a commit whose resize callback hasn't run, not rows above the reading place, not a clamp to a shorter end. The window and both follow transitions use that one verdict; the reading place is re-taken after each resize callback, so a correction that couldn't land is never written later; no write lands while iOS bounces past an end. Replaces the one-second input window.

* fix(mobile): a jump away from the end while following draws where it lands, not every row to the end

The jump's scroll event covered with following still set, which keeps every row from the new start to the end: up to 1,603 rows mounted in one 1.8 s task before the follow change trimmed them.

* fix(mobile): Tools and text size keep a parked reader's place on the page

Toggling Tools regroups the rows, so the window's reading place (a row key) can vanish and nothing put the reader back: they moved about 20 messages. The place-hold, which already follows what the reader was reading through a regroup, now also runs when how rows draw changes.

* perf(mobile): measuring rows costs the same at any chat length

Each settled moment walked every measured row (resolving and comparing its state) while following, so the fill grew slower as it went (27-43 ms per round at 4x). Now one round checks rows within three screens of the reader, fills the next rows just above the measured run directly, and walks every row at most once a second; a round measures 12 rows, not 24.

* style(mobile): window hook back under its line cap

* fix(mobile): iOS bouncing past the end is the reader's scroll, not a clamp

The clamp test matched any position at or past the end, so every rubber-band frame read as layout: the page counted the reader as settled mid-bounce and an older page landed during it, with a one-to-two-frame blank. A clamp now lands exactly on the new end from a position the old one allowed.

* fix(mobile): a drawn measuring group waits for its measurement, so a landing while following doesn't redraw 30 times

After a landing while following, the next choice ran before the browser measured the group just drawn: the group sat at the top, so nothing looked owed above it and the group came down unmeasured, then went up again, 28 times in 180 ms until the pin landed. The choice now waits while the group's rows have never been measured, and re-renders only when it changes. Choosing what to measure moves to the heights; the re-check timer to the motion.

* fix(mobile): a correction puts back what layout moved, never the reader's own scroll

The browser can run a resize callback before the scroll event of a wheel, key or scrollbar move; the correction measured the reading row's move on screen, which included that scroll, and wrote it back (300 px per wheel tick while rows above were measured). The correction now puts back only the row's move within the content, keeps the scroll, and undoes only a clamp. A scroll that lands while a commit is unreconciled is left for the resize callback to tell apart, so a reader's move there still counts as theirs.

* fix(mobile): the end pin waits for the reader's move to be counted; measuring stays cheap while a reply streams

- The resize callback pinned to the end before telling apart a move no scroll event had reported, so a reader's first scroll up while following could be written back. The list now counts that move (which may end following) and then runs the pin.
- The nearby stale walk checked every measured row before its three-screen reach applied (2,236 checks per streamed update at 4x); the reach now bounds the walk.
- Measuring rounds draw 8 rows and leave 32 ms between rounds, so a streamed reply gets frames while rows are measured.

* fix(mobile): Collapse keeps the place when the browser hasn't applied a clamp yet

Collapsing shrank rows above a parked reader in rounds; when a round left the scroll past the new end before the browser clamped it, the write guard took it for an iOS end bounce and skipped the correction, and the window re-seated at the end. Corrections wait for rest, so past-the-end there is a pending clamp, not a bounce; only above-the-top stays guarded.

* fix(mobile): no second correction when the browser's own scroll anchoring already kept the row

On Chromium, rows landing above a parked reader are held on screen by the browser's scroll anchoring, which moves the scroll. The resize callback counted that move as the reader's, kept it, and put the row's content shift back on top: the row left the screen by 1.4-2.8k px. When the reading row is where it was on screen, the move is layout's and nothing is owed.

* fix(mobile): the page alone keeps a parked reader's place; the transcript is out of browser scroll anchoring

On Chromium the browser's scroll anchoring and the page's own correction both answered the same layout change, and the overlap can't be measured after the fact: a reader's wheel tick in the frame of a landing still got the landing put back twice (0.9-1.6k px). The transcript is now never a browser anchor, as on WebKit, and the end marker anchors only while following; the overlap guess (placeHeld) is gone.

* fix(mobile): no browser anchor while following either; only the page moves the scroll

The 1 px end marker stayed a browser anchor while following, so rows measured in above made the browser move the scroll to keep the end; the resize callback counted that move as the reader's and every measuring round waited for rest (spinner 36 s instead of 2.7 s on a 2,000-row open). The end pin already holds a following reader before paint and a shrink clamps onto the end, so the marker and its anchoring go.

* fix(mobile): on Chromium the page keeps a reader's place while they scroll, as the common list does

With the transcript out of browser scroll anchoring, corrections that waited for rest left Chromium readers (Android, desktop) seeing rows above change size mid-scroll, worse than main. Where a scroll write keeps momentum (every engine but WebKit), the resize callback's correction and the place-hold now write at once, as the common list does; WebKit, which stops a fling on any write and has no anchoring, still waits for rest.

* fix(mobile): a parked reader's place is the part of the row at the screen's top, not the row's own top

With browser anchoring off, growth inside the partly visible top row above the reading point (a tool card or image getting taller) left the row's top in place, so nothing corrected it: the text moved by the growth (240 px), where main's browser anchoring held it. The page now anchors the part of that row at the screen's top, as the browser's anchoring would pick; a following reader keeps the row's top, which the end pin makes enough.

* fix(mobile): on Chromium the browser keeps a parked reader's place; the page owns only following and a regroup

The page kept a parked reader's place itself with a saved element at the screen's top, which a
replaced card inside the top row took away (+240 px, main 0). Where the browser has scroll
anchoring it now holds the parked place, as main does; the end pin holds a following reader; the
place-hold takes the scroll for a regroup or a move to another row, with anchoring off from before
the update until its correction. Only input that goes the move's way makes a move the reader's
there, so the browser's moves never end or resume following. WebKit is unchanged. While following,
measuring waits for a streaming reply to pause.

* test(mobile): the page's own hold is tested as on iOS; layout settles before the reader's wheel

* style(mobile): format the page's anchoring change

* fix(mobile): the browser chooses its anchor again once the reader rests, after a scroll that landed on a spacer

A long jump can land where only a spacer is drawn; spacers are never an anchor, so Chromium chose
none and kept none after rows mounted there, until the next scroll (growth above then moved the
reader +240 px; main 0). At rest after a scroll the page nudges the scroll by a pixel and back, so
the browser chooses from the rows drawn now.

* fix(mobile): at the very top the page keeps a parked reader's place, where Chromium's anchoring never adjusts

Parked at scroll offset 0 while older rows were still being measured above, each measured group
pushed the reader down 0.9-1.2k px: Chromium makes no anchoring adjustment at offset 0, and the
page wrote no correction there. The page now takes its anchor and corrects only at the very top;
everywhere else the browser keeps the place, so the two never both move it.

* fix(mobile): after each commit the browser chooses its anchor from the rows laid out, not the container around them

Chromium keeps a partly visible container as its anchor when nothing inside qualifies; rows it
never laid out don't, and spacers never do. After a long scroll onto a spacer the anchor was the
transcript's wrapper, whose top never moves, until the next scroll: growth in the first ~150 ms
moved the reader +240 px (main 0). After each commit, while the browser holds the place, the page
lays the rows out and scrolls a pixel and back (its own writes), so Chromium chooses among them.
The settle-time re-choose goes.

* fix(mobile): the re-choose nudge is not taken as the page's own writes, so a reader's scroll landing there still counts

The nudge ends where it started, so the scroll event after it reports only what else moved the
scroll. Recorded as the page's own targets, it swallowed the End key's move to the end (within a
pixel of a target), which then didn't resume following (main and f044 did).

* fix(mobile): preserve native chat state across page windows

* test(mobile): use typed RPC fake for chat paging

* fix(mobile): on iOS a Tools toggle keeps the reader's place even while the list still glides

The page's WebKit place-hold wrote its correction only at rest, so a regroup committed within
150 ms of a reader scroll (or mid-glide after a tap outside the list) skipped it, and nothing
redid it: the text the reader was on moved by the regrouped rows' change and stayed there.
Unlike a source change, whose new rows stay out of the spacer until measured at rest, a regroup
resizes drawn rows at once. It now corrects at once, as main's hold always did.

* Preserve reading positions within phone chat scroll boxes

* Correct phone chat type declarations

* fix(mobile): a browser notice elsewhere on the phone page no longer fails a terminal that is still loading

On the page each terminal document listens for window errors, and before ready a reported error is
fatal ("Terminal failed to load"). The window is the whole app's, so the browser's ResizeObserver
loop notice, which the chat list's resize work can raise and which carries no error, reached a
terminal mounted in the background and failed it. The guest reporter now forwards only events with
an error behind them.

* fix(mobile): a parked reader stays parked when a collapse below them clamps the phone page to its end

After every resize the page attached following whenever the scroll sat within 1 px of the end,
whatever put it there. A collapse below a reader parked near the end makes WebKit clamp onto the
shorter end, so the next reply pulled them down. Following now comes only from classified motion
(the reader's own move, or a move toward the end), as on main; the resize callback only pins.

* refactor(mobile): the chat's row list, page window or native FlatList, is its own component

Main's composer and tab changes took MobileNativeChatView past its line cap once merged; the
platform choice of list and its wiring move out unchanged.

* fix(mobile): only a move toward the end makes a parked phone-page reader follow

A clamp onto a shorter end that lands while the reader's finger is still down from tapping a
collapse below them is classified as the reader's move, and any reader move ending at the end
attached following. Following now attaches only on a move down toward the end, as on main; the
test covers the clamp with and without a finger down.

* fix(mobile): a slow drag up detaches a following phone-page reader

Detach needed a single scroll event moving up more than 1px, so a slow drag that reports many 1px
moves never detached, and the next reply pulled the reader back to the end. Any upward move of the
reader's that leaves the end now detaches. Found by tracing the remaining iPhone jump.

* test(mobile): reconcile each step of the slow-drag detach test, as the browser's frame does

* test(mobile): run the slow-drag detach test as iOS Safari, where the bug was seen

* Fix following after a slow return to the chat end

* fix(mobile): closing a tool line forgets its nested command and output scroll positions

A nested box's address is the run's key, then the line's key:command, so the
old prefix match on the closed key never reached it and a reopened line
restored a stale scroll position.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
2026-10-19 13:50:11 -07:00

276 lines
10 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 { truncateToolDetail } from '../../../src/shared/native-chat-tool-summary'
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 { 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 ? (
<MobileNativeChatOwnedScrollView
rootIdentity={`${memberId ?? body?.callId ?? 'detail'}:output`}
positionKey={`${disclosureKey ?? body?.callId ?? 'detail'}:output`}
style={[styles.codeBox, styles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.codeText, type, body.isError && styles.errorText]}
>
{truncateToolDetail(body.output)}
</Text>
</MobileNativeChatOwnedScrollView>
) : 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>
)
}