mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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>
This commit is contained in:
co-authored by
Orca Worker
OrcaWin
parent
bd7633f6eb
commit
b0c55557f0
+349
-337
File diff suppressed because it is too large
Load Diff
+74
-72
@@ -115,6 +115,72 @@
|
||||
"status": "ready",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"911b3f485409": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "nativeChat.readSession"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"agent": "claude",
|
||||
"beforeOffset": 1200,
|
||||
"limit": 60,
|
||||
"sessionId": "session-1",
|
||||
"transcriptPath": "/work/feature/.claude/session-1.jsonl"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"beforeOffset": 0,
|
||||
"hasMore": false,
|
||||
"messages": [
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"text": "first",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "m-1",
|
||||
"role": "assistant",
|
||||
"source": "transcript",
|
||||
"timestamp": 1000
|
||||
},
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"text": "second",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "m-2",
|
||||
"role": "assistant",
|
||||
"source": "transcript",
|
||||
"timestamp": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"96abfc4cef7e": {
|
||||
"name": "nativeChat.unsubscribe#1",
|
||||
"ordinal": 4,
|
||||
@@ -185,7 +251,7 @@
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"d83fbb18104a": {
|
||||
"e88c218171fa": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
@@ -206,72 +272,8 @@
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"beforeOffset": 0,
|
||||
"hasMore": false,
|
||||
"messages": [
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"text": "first",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "m-1",
|
||||
"role": "assistant",
|
||||
"source": "transcript",
|
||||
"timestamp": 1000
|
||||
},
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"text": "second",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "m-2",
|
||||
"role": "assistant",
|
||||
"source": "transcript",
|
||||
"timestamp": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dadae0a56731": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "nativeChat.readSession"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"agent": "claude",
|
||||
"beforeOffset": 1200,
|
||||
"limit": 60,
|
||||
"sessionId": "session-1",
|
||||
"transcriptPath": "/work/feature/.claude/session-1.jsonl"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -361,7 +363,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:paging",
|
||||
"observation": {
|
||||
"sender": ["dadae0a56731"],
|
||||
"sender": ["e88c218171fa"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -374,7 +376,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:paged",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -387,7 +389,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:re-subscribed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -401,7 +403,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -415,7 +417,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
|
||||
+65
-63
@@ -105,40 +105,7 @@
|
||||
"status": "ready",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"a5609c6d15fc": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "Unknown method",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"b96b63e4aaf3": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "outer refused",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"d53372f95573": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"d83fbb18104a": {
|
||||
"911b3f485409": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
@@ -159,7 +126,8 @@
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -203,7 +171,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dadae0a56731": {
|
||||
"a5609c6d15fc": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "Unknown method",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"b96b63e4aaf3": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "outer refused",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"d53372f95573": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": "",
|
||||
"hasMore": false,
|
||||
"loadingEarlier": false,
|
||||
"messageIds": ["m-1", "m-2", "m-3", "m-4"],
|
||||
"status": "error",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"e4483b4ab66e": {
|
||||
"name": "nativeChat.unsubscribe#2",
|
||||
"ordinal": 7,
|
||||
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}"
|
||||
},
|
||||
"e88c218171fa": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
@@ -224,7 +230,8 @@
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -233,11 +240,6 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"e4483b4ab66e": {
|
||||
"name": "nativeChat.unsubscribe#2",
|
||||
"ordinal": 7,
|
||||
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"nativeChat.unsubscribe\",\"params\":{\"subscriptionId\":\"claude:session-1:00000000-0000-4000-8000-000000000001\"}}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -290,7 +292,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.prelude:paging",
|
||||
"observation": {
|
||||
"sender": ["dadae0a56731"],
|
||||
"sender": ["e88c218171fa"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -303,7 +305,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.prelude:paged",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -316,7 +318,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.prelude:re-subscribed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -330,7 +332,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -344,7 +346,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.normal:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -365,7 +367,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.result-absent:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -379,7 +381,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.result-absent:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -400,7 +402,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.result-null:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -414,7 +416,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.result-null:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -435,7 +437,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-ok-missing:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -449,7 +451,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-ok-missing:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -470,7 +472,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-false-string-error:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -484,7 +486,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-false-string-error:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -505,7 +507,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-false-object-error:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -519,7 +521,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.inner-false-object-error:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
@@ -540,7 +542,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.outer-refused:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -554,7 +556,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.outer-refused:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -569,7 +571,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.outer-refused-no-message:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -583,7 +585,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.outer-refused-no-message:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -598,7 +600,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.method-not-found:replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -612,7 +614,7 @@
|
||||
{
|
||||
"id": "native-chat-page-earlier.method-not-found:unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"status": "ready",
|
||||
"transcriptLoading": false
|
||||
},
|
||||
"d83fbb18104a": {
|
||||
"911b3f485409": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
@@ -102,7 +102,8 @@
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -146,7 +147,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dadae0a56731": {
|
||||
"e88c218171fa": {
|
||||
"name": "nativeChat.readSession#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
@@ -167,7 +168,8 @@
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"budgetSpansConnect": true,
|
||||
"timeoutMs": 15000
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -228,7 +230,7 @@
|
||||
{
|
||||
"id": "paging",
|
||||
"observation": {
|
||||
"sender": ["dadae0a56731"],
|
||||
"sender": ["e88c218171fa"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -241,7 +243,7 @@
|
||||
{
|
||||
"id": "paged",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -254,7 +256,7 @@
|
||||
{
|
||||
"id": "re-subscribed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -268,7 +270,7 @@
|
||||
{
|
||||
"id": "replayed",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": ["456781b97acc", "08ef53d83e8c", "663babae1f1d", "7eedfcf76fd3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
@@ -282,7 +284,7 @@
|
||||
{
|
||||
"id": "unmounted",
|
||||
"observation": {
|
||||
"sender": ["d83fbb18104a"],
|
||||
"sender": ["911b3f485409"],
|
||||
"payloads": [
|
||||
"456781b97acc",
|
||||
"08ef53d83e8c",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
import { ChevronRight, FileMinus2, FilePen, FilePlus2 } from 'lucide-react-native'
|
||||
import {
|
||||
NATIVE_CHAT_DIFF_CARD_COPY,
|
||||
@@ -83,7 +84,17 @@ function DiffRow({
|
||||
|
||||
/** The card's rows in their own bounded box. Its own component so a collapsed card
|
||||
* builds none of them. */
|
||||
function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale: number }) {
|
||||
function DiffCardRows({
|
||||
file,
|
||||
fontScale,
|
||||
disclosureKey,
|
||||
rootIdentity
|
||||
}: {
|
||||
file: NativeChatEditFile
|
||||
fontScale: number
|
||||
disclosureKey?: string
|
||||
rootIdentity?: string
|
||||
}) {
|
||||
const onLayout = useMobileNativeChatOpenedDetail()
|
||||
const type = toolType(fontScale).code
|
||||
// A snippet-relative number would read as a file position, so the gutter is blank
|
||||
@@ -95,7 +106,9 @@ function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale
|
||||
// Desktop's row identity: the line itself, and which repeat of it this is.
|
||||
const seen = new Map<string, number>()
|
||||
return (
|
||||
<ScrollView
|
||||
<MobileNativeChatOwnedScrollView
|
||||
rootIdentity={rootIdentity ?? `diff:${file.path}`}
|
||||
positionKey={disclosureKey ?? `diff:${file.path}`}
|
||||
style={[styles.codeBox, styles.diffBody]}
|
||||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -114,7 +127,7 @@ function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,6 +138,7 @@ export function MobileNativeChatDiffCard({
|
||||
fontScale,
|
||||
defaultExpanded = false,
|
||||
disclosureKey,
|
||||
rootIdentity,
|
||||
onOpenFile
|
||||
}: {
|
||||
file: NativeChatEditFile
|
||||
@@ -132,6 +146,7 @@ export function MobileNativeChatDiffCard({
|
||||
defaultExpanded?: boolean
|
||||
/** Identity this card's open state is remembered under while it is unmounted. */
|
||||
disclosureKey?: string
|
||||
rootIdentity?: string
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, defaultExpanded)
|
||||
@@ -201,7 +216,14 @@ export function MobileNativeChatDiffCard({
|
||||
accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.copyDiff}
|
||||
/>
|
||||
</View>
|
||||
{hasBody && expanded ? <DiffCardRows file={file} fontScale={fontScale} /> : null}
|
||||
{hasBody && expanded ? (
|
||||
<DiffCardRows
|
||||
file={file}
|
||||
fontScale={fontScale}
|
||||
disclosureKey={disclosureKey}
|
||||
rootIdentity={rootIdentity}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export function MobileNativeChatLiveLine({
|
||||
{reasoning && open ? (
|
||||
<View style={styles.body}>
|
||||
<MobileNativeChatReasoningBody
|
||||
positionKey={nativeChatReasoningDisclosureKey(reasoning.message.id)}
|
||||
markdown={reasoning.markdown}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
|
||||
@@ -68,6 +68,10 @@ vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import {
|
||||
MobileNativeChatDisclosureContext,
|
||||
useMobileNativeChatDisclosures
|
||||
} from './mobile-native-chat-disclosure-store'
|
||||
|
||||
function message(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||||
return { id: 'source', role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
|
||||
@@ -127,6 +131,64 @@ describe('mobile tool result availability', () => {
|
||||
return renderer!
|
||||
}
|
||||
|
||||
// The page's window unmounts a row scrolled past; older messages joining its fold change its id.
|
||||
it('keeps what the reader opened in a windowed-out row by its row key, per Tools default', () => {
|
||||
const row = message([
|
||||
{ type: 'tool-call', callId: 'one', name: 'Bash', input: 'first input' },
|
||||
{ type: 'tool-result', callId: 'one', output: 'first output' },
|
||||
{ type: 'tool-call', callId: 'two', name: 'Bash', input: 'second input' },
|
||||
{ type: 'tool-result', callId: 'two', output: 'second output' }
|
||||
])
|
||||
function Window({ shown, id, tools }: { shown: boolean; id: string; tools: boolean }) {
|
||||
const disclosures = useMobileNativeChatDisclosures('chat')
|
||||
return createElement(
|
||||
MobileNativeChatDisclosureContext.Provider,
|
||||
{ value: disclosures },
|
||||
shown
|
||||
? createElement(MobileNativeChatMessage, {
|
||||
message: { ...row, id },
|
||||
rowKey: 'kept',
|
||||
toolsExpanded: tools
|
||||
})
|
||||
: null
|
||||
)
|
||||
}
|
||||
const draw = (shown: boolean, id = 'source', tools = false) =>
|
||||
act(() => {
|
||||
const element = createElement(Window, { shown, id, tools })
|
||||
if (renderer) {
|
||||
renderer.update(element)
|
||||
} else {
|
||||
renderer = create(element)
|
||||
}
|
||||
})
|
||||
const header = () =>
|
||||
renderer!.root.find(
|
||||
(node) =>
|
||||
String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion === 'polite'
|
||||
)
|
||||
draw(true)
|
||||
press(header())
|
||||
press(button(renderer!.root, 'first input'))
|
||||
draw(false)
|
||||
draw(true, 'older-head')
|
||||
expect(toolLines(renderer!.root)).toHaveLength(2)
|
||||
expect(textIn(renderer!.root)).toContain('first output')
|
||||
expect(textIn(renderer!.root)).not.toContain('second output')
|
||||
|
||||
// Tools opens every run and line; the reader closes this run under that default.
|
||||
draw(true, 'older-head', true)
|
||||
expect(textIn(renderer!.root)).toContain('second output')
|
||||
press(header())
|
||||
draw(false, 'older-head', true)
|
||||
draw(true, 'older-head', true)
|
||||
expect(toolLines(renderer!.root)).toHaveLength(0)
|
||||
// Back to the old default: the choices made under it come back.
|
||||
draw(true, 'older-head', false)
|
||||
expect(textIn(renderer!.root)).toContain('first output')
|
||||
expect(textIn(renderer!.root)).not.toContain('second output')
|
||||
})
|
||||
|
||||
it('draws each orphan output as its own Result row under one Result header', () => {
|
||||
const raw = message(
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 { 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'
|
||||
@@ -47,6 +48,8 @@ import { Prose } from './MobileNativeChatProse'
|
||||
|
||||
function MobileNativeChatMessageImpl({
|
||||
message,
|
||||
rowKey,
|
||||
runOwner,
|
||||
mayStillGrow = false,
|
||||
toolsExpanded = false,
|
||||
previousTodoWrite,
|
||||
@@ -77,6 +80,9 @@ function MobileNativeChatMessageImpl({
|
||||
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
|
||||
@@ -213,7 +219,7 @@ function MobileNativeChatMessageImpl({
|
||||
return (
|
||||
<>
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} testID={mobileNativeChatRowId(rowKey ?? message.id)}>
|
||||
<MobileNativeChatResolutionReceipt
|
||||
body={receipt}
|
||||
awaiting={awaiting}
|
||||
@@ -234,7 +240,7 @@ function MobileNativeChatMessageImpl({
|
||||
<>
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
{draws ? (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} testID={mobileNativeChatRowId(rowKey ?? message.id)}>
|
||||
<MobileNativeChatReasoningRow
|
||||
message={message}
|
||||
markdown={markdown}
|
||||
@@ -281,7 +287,10 @@ function MobileNativeChatMessageImpl({
|
||||
<>
|
||||
{/* A turn with no user bubble carries its bar above its first row. */}
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<View
|
||||
style={[styles.row, isUser && styles.rowUser]}
|
||||
testID={mobileNativeChatRowId(rowKey ?? message.id)}
|
||||
>
|
||||
<Content
|
||||
onLongPress={onLongPress}
|
||||
style={[
|
||||
@@ -335,7 +344,7 @@ function MobileNativeChatMessageImpl({
|
||||
// default every run and line reads, and a reader's own choice is kept per default.
|
||||
defaultExpanded={toolsExpanded}
|
||||
expandChildren={toolsExpanded}
|
||||
disclosureId={message.id}
|
||||
disclosureId={runOwner ?? rowKey ?? message.id}
|
||||
asides={asides}
|
||||
activeTurnIsWorking={structuredActivityUi ? activeTurnIsWorking : undefined}
|
||||
trailing={trailingRun}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatFoldReuse } from './mobile-native-chat-fold-reuse'
|
||||
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
import type { MobileNativeChatStructuredComposer } from './MobileNativeChatComposer'
|
||||
@@ -69,9 +70,9 @@ export function MobileNativeChatOverlay({
|
||||
const session = controller.nativeChatSession
|
||||
// The chat is drawn only with a resolved agent; the fallback never reaches the screen.
|
||||
const agent = controller.nativeChatAgent ?? ''
|
||||
const folded = useMemo(
|
||||
() => foldMobileNativeChatMessages(session.messages, agent),
|
||||
[session.messages, agent]
|
||||
const folded = useMobileNativeChatFoldReuse(
|
||||
useMemo(() => foldMobileNativeChatMessages(session.messages, agent), [session.messages, agent]),
|
||||
sendSurfaceId
|
||||
)
|
||||
const streaming = useMobileNativeChatStreamingBubble(
|
||||
folded,
|
||||
@@ -142,7 +143,7 @@ export function MobileNativeChatOverlay({
|
||||
composerTray={composerTray}
|
||||
onOpenFile={onOpenFile}
|
||||
hasMore={session.hasMore}
|
||||
loadingEarlier={session.loadingEarlier}
|
||||
olderPages={session.olderPages}
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
onSend={images.sendNativeChat}
|
||||
sendSurfaceId={sendSurfaceId}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentProps,
|
||||
type Ref
|
||||
} from 'react'
|
||||
import { Platform, ScrollView } from 'react-native'
|
||||
import { MobileNativeChatDisclosureContext } from './mobile-native-chat-disclosure-store'
|
||||
|
||||
const RootAddress = createContext<{ id: string; address: string; owner: string } | null>(null)
|
||||
|
||||
/** Every vertical transcript viewport declares its owner, even before it overflows. */
|
||||
export function MobileNativeChatOwnedScrollView({
|
||||
positionKey,
|
||||
rootIdentity = positionKey,
|
||||
owner,
|
||||
initialFollow = false,
|
||||
followKey,
|
||||
ref,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof ScrollView> & {
|
||||
positionKey: string
|
||||
rootIdentity?: string
|
||||
owner?: string
|
||||
initialFollow?: boolean
|
||||
followKey?: number
|
||||
ref?: Ref<ScrollView>
|
||||
}) {
|
||||
const store = useContext(MobileNativeChatDisclosureContext)
|
||||
const parent = useContext(RootAddress)
|
||||
const id = parent ? `${parent.id}/${rootIdentity}` : rootIdentity
|
||||
const address = parent ? `${parent.address}/${positionKey}` : positionKey
|
||||
const rootOwner = owner ?? parent?.owner ?? id
|
||||
const value = useMemo(() => ({ id, address, owner: rootOwner }), [id, address, rootOwner])
|
||||
const roots = store?.roots
|
||||
const scroll = useRef<ScrollView>(null)
|
||||
const release = useRef<(() => void) | undefined>(undefined)
|
||||
const attach = useCallback(
|
||||
(node: ScrollView | null) => {
|
||||
scroll.current = node
|
||||
if (typeof ref === 'function') {
|
||||
return ref(node)
|
||||
}
|
||||
if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
release.current?.()
|
||||
release.current = undefined
|
||||
},
|
||||
[roots, id]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS !== 'web' || !roots) {
|
||||
return
|
||||
}
|
||||
const node: unknown = scroll.current?.getScrollableNode()
|
||||
if (
|
||||
typeof HTMLElement !== 'undefined' &&
|
||||
node instanceof HTMLElement &&
|
||||
node.firstElementChild !== null
|
||||
) {
|
||||
release.current = roots.register(
|
||||
id,
|
||||
node,
|
||||
node.firstElementChild,
|
||||
rootOwner,
|
||||
address,
|
||||
initialFollow
|
||||
)
|
||||
roots.layout(id, address, followKey)
|
||||
}
|
||||
})
|
||||
return (
|
||||
<ScrollView {...props} ref={attach}>
|
||||
<RootAddress.Provider value={value}>{children}</RootAddress.Provider>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type Ref
|
||||
} from 'react'
|
||||
import {
|
||||
View,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ScrollView,
|
||||
type StyleProp,
|
||||
type ViewStyle
|
||||
} from 'react-native'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import { MobileNativeChatPageScroll, outOfAnchoring } from './MobileNativeChatPageScroll'
|
||||
import type { MobileNativeChatListHandle } from './use-mobile-native-chat-tail-follow'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure'
|
||||
import type { MobileNativeChatWindowScroller } from './mobile-native-chat-page-window-state'
|
||||
import { useMobileNativeChatPageWindow } from './use-mobile-native-chat-page-window'
|
||||
import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots'
|
||||
|
||||
type Row = { item: NativeChatMessage; index: number }
|
||||
|
||||
type Props = {
|
||||
roots?: MobileNativeChatScrollRoots
|
||||
ref: Ref<MobileNativeChatListHandle>
|
||||
endAnchored: boolean
|
||||
isFollowing: () => boolean
|
||||
pin: () => void
|
||||
rows: readonly NativeChatMessage[]
|
||||
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'keyOf' | 'current'>
|
||||
renderItem: (row: Row) => ReactElement
|
||||
/** What a row draws besides itself; a height measured for other state no longer holds. */
|
||||
rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow
|
||||
layoutKey: string
|
||||
contentContainerStyle: StyleProp<ViewStyle>
|
||||
/** `measuring`: rows above are not measured yet, so the Load earlier slot shows loading. */
|
||||
header: (measuring: boolean) => ReactElement | null
|
||||
footer: ReactElement | null
|
||||
emptyStateView: ReactElement | null
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
|
||||
}
|
||||
|
||||
/** The page's transcript in one keyed flow: RN Web's list draws by index after paint, so opens climbed
|
||||
* and older pages went in its header, where iOS stopped scrolling at the seam. */
|
||||
export function MobileNativeChatPageList({
|
||||
ref,
|
||||
roots,
|
||||
endAnchored,
|
||||
isFollowing,
|
||||
pin,
|
||||
rows,
|
||||
rowKeys,
|
||||
renderItem,
|
||||
rowState,
|
||||
layoutKey,
|
||||
contentContainerStyle,
|
||||
header,
|
||||
footer,
|
||||
emptyStateView,
|
||||
onScroll
|
||||
}: Props): ReactElement {
|
||||
const scroll = useRef<ScrollView>(null)
|
||||
const scroller = useCallback(
|
||||
(): MobileNativeChatWindowScroller | null | undefined => scroll.current?.getScrollableNode(),
|
||||
[]
|
||||
)
|
||||
const { view, rowRef, list } = useMobileNativeChatPageWindow({
|
||||
roots,
|
||||
rows,
|
||||
rowKeys,
|
||||
rowState,
|
||||
following: endAnchored,
|
||||
isFollowing,
|
||||
layoutKey,
|
||||
scroller
|
||||
})
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getScrollableNode: () => scroll.current?.getScrollableNode(),
|
||||
scrollToOffset: ({ offset, animated }) => {
|
||||
list.wrote(offset)
|
||||
scroll.current?.scrollTo({ y: offset, animated })
|
||||
},
|
||||
scrollToEnd: (params) => scroll.current?.scrollToEnd(params),
|
||||
...list
|
||||
}),
|
||||
[list]
|
||||
)
|
||||
// After the handle attaches, so a chat's first paint is at its end; later growth the observer pins.
|
||||
const opened = useRef('')
|
||||
const shown = `${rowKeys.scope}:${rows.length > 0}`
|
||||
useLayoutEffect(() => {
|
||||
if (opened.current !== shown) {
|
||||
opened.current = shown
|
||||
pin()
|
||||
}
|
||||
}, [shown, pin])
|
||||
|
||||
const drawRow = (item: NativeChatMessage, index: number) => {
|
||||
const key = rowKeys.keyOf(item)
|
||||
return (
|
||||
<View key={key} ref={rowRef(key)}>
|
||||
{renderItem({ item, index })}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MobileNativeChatPageScroll
|
||||
ref={scroll}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
// Let link/file taps land while the composer keyboard is up
|
||||
// instead of being swallowed by the dismiss gesture.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={32}
|
||||
>
|
||||
{/* Never what the browser's anchoring holds: rows land below it, and spacers stand in for rows. */}
|
||||
<View style={outOfAnchoring}>{header(view.measuring)}</View>
|
||||
{rows.length === 0 ? (
|
||||
emptyStateView
|
||||
) : (
|
||||
// Keyed by the chat, so no row carries into another. Rows unmount once scrolled past; what
|
||||
// the reader opened in them is kept in the chat's disclosure store, above the list.
|
||||
<Fragment key={rowKeys.scope}>
|
||||
<View style={[outOfAnchoring, { height: view.above }]} />
|
||||
{rows.slice(view.m0, view.m1).map((item, at) => drawRow(item, view.m0 + at))}
|
||||
<View style={[outOfAnchoring, { height: view.mid }]} />
|
||||
{rows.slice(view.start, view.end).map((item, at) => drawRow(item, view.start + at))}
|
||||
<View style={[outOfAnchoring, { height: view.bottom }]} />
|
||||
</Fragment>
|
||||
)}
|
||||
{footer}
|
||||
</MobileNativeChatPageScroll>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Ref } from 'react'
|
||||
import { ScrollView, StyleSheet, View, type ScrollViewProps, type ViewStyle } from 'react-native'
|
||||
|
||||
/** The page's scroller. Where the browser has scroll anchoring it keeps a parked reader's place;
|
||||
* the page turns it off on the scroller itself while it owns the scroll (following, a place-hold). */
|
||||
export function MobileNativeChatPageScroll({
|
||||
ref,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: ScrollViewProps & { ref?: Ref<ScrollView> }) {
|
||||
return (
|
||||
<ScrollView ref={ref} {...props} contentContainerStyle={pageScroll.fill}>
|
||||
<View style={contentContainerStyle}>{children}</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
// RN Web passes it through to CSS; ViewStyle doesn't name it.
|
||||
const anchorless: ViewStyle & { overflowAnchor: 'none' } = { overflowAnchor: 'none' }
|
||||
|
||||
const pageScroll = StyleSheet.create({
|
||||
fill: { flexGrow: 1 },
|
||||
anchorless
|
||||
})
|
||||
|
||||
/** Never the node the browser's anchoring holds. */
|
||||
export const outOfAnchoring = pageScroll.anchorless
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mobileNativeChatMemberId } from './MobileNativeChatScrollHold'
|
||||
import { useState } from 'react'
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
import { Brain, ChevronRight } from 'lucide-react-native'
|
||||
import {
|
||||
nativeChatReasoningDisclosureKey,
|
||||
@@ -43,7 +45,7 @@ export function MobileNativeChatReasoningRow({
|
||||
const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message, { live }))
|
||||
const label = nativeChatReasoningHeadlineText({ kind: 'reasoning' })
|
||||
return (
|
||||
<View>
|
||||
<View testID={mobileNativeChatMemberId(`thought:${message.id}`)}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.reasoningToggle, pressed && styles.reasoningPressed]}
|
||||
onPress={toggle}
|
||||
@@ -63,6 +65,7 @@ export function MobileNativeChatReasoningRow({
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<MobileNativeChatReasoningBody
|
||||
positionKey={nativeChatReasoningDisclosureKey(message.id)}
|
||||
markdown={markdown}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
@@ -76,18 +79,24 @@ export function MobileNativeChatReasoningRow({
|
||||
/** A reasoning block's text, under its row or the live activity line. Capped and scrollable, so an
|
||||
* open block streaming at the tail cannot grow without bound. */
|
||||
export function MobileNativeChatReasoningBody({
|
||||
positionKey,
|
||||
markdown,
|
||||
fontScale,
|
||||
onOpenFile,
|
||||
onLongPress
|
||||
}: {
|
||||
positionKey: string
|
||||
markdown: string
|
||||
fontScale: number
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
onLongPress?: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ScrollView style={styles.reasoningBody} nestedScrollEnabled>
|
||||
<MobileNativeChatOwnedScrollView
|
||||
positionKey={positionKey}
|
||||
style={styles.reasoningBody}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<MobileNativeChatLongPressContent onLongPress={onLongPress} style={styles.reasoning}>
|
||||
<MobileMarkdown
|
||||
content={markdown}
|
||||
@@ -97,6 +106,6 @@ export function MobileNativeChatReasoningBody({
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
</MobileNativeChatLongPressContent>
|
||||
</ScrollView>
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { FlatList, Platform } from 'react-native'
|
||||
import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import { MobileNativeChatPageList } from './MobileNativeChatPageList'
|
||||
import {
|
||||
mobileNativeChatRowAnchor,
|
||||
type MobileNativeChatTailFollow
|
||||
} from './use-mobile-native-chat-tail-follow'
|
||||
|
||||
type PageProps = ComponentProps<typeof MobileNativeChatPageList>
|
||||
|
||||
type Props = Pick<
|
||||
PageProps,
|
||||
| 'rows'
|
||||
| 'roots'
|
||||
| 'renderItem'
|
||||
| 'rowState'
|
||||
| 'layoutKey'
|
||||
| 'contentContainerStyle'
|
||||
| 'header'
|
||||
| 'footer'
|
||||
| 'emptyStateView'
|
||||
| 'onScroll'
|
||||
> & {
|
||||
tail: MobileNativeChatTailFollow
|
||||
rowKeys: MobileNativeChatRowKeys
|
||||
/** The native list's header; with one, the row below it anchors the visible content. */
|
||||
nativeHeader: ReactElement | null
|
||||
hasHeader: boolean
|
||||
}
|
||||
|
||||
/** The chat's rows: the page draws its own keyed window, a native list recycles through FlatList. */
|
||||
export function MobileNativeChatRowList(props: Props): ReactElement {
|
||||
const { tail, rowKeys, rows, renderItem, contentContainerStyle, onScroll, footer } = props
|
||||
if (Platform.OS === 'web') {
|
||||
const { roots, rowState, header, emptyStateView, layoutKey } = props
|
||||
return (
|
||||
<MobileNativeChatPageList
|
||||
{...tail.pageListProps}
|
||||
{...{ rows, rowKeys, roots, renderItem, rowState, contentContainerStyle }}
|
||||
{...{ footer, emptyStateView, onScroll, header, layoutKey }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
{...tail.listProps}
|
||||
data={rows}
|
||||
keyExtractor={rowKeys.keyOf}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
// Let link/file taps land while the composer keyboard is up
|
||||
// instead of being swallowed by the dismiss gesture.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={onScroll}
|
||||
onScrollBeginDrag={tail.beginUserScroll}
|
||||
onScrollEndDrag={tail.endUserDrag}
|
||||
onMomentumScrollBegin={tail.beginMomentum}
|
||||
onMomentumScrollEnd={tail.endMomentum}
|
||||
scrollEventThrottle={32}
|
||||
onContentSizeChange={tail.pinToTailAfterContentResize}
|
||||
onLayout={tail.pinToTail}
|
||||
maintainVisibleContentPosition={mobileNativeChatRowAnchor(props.hasHeader)}
|
||||
ListHeaderComponent={props.nativeHeader}
|
||||
ListFooterComponent={footer}
|
||||
ListEmptyComponent={props.emptyStateView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
|
||||
|
||||
const platform = vi.hoisted(() => ({ OS: 'web' }))
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: platform }))
|
||||
|
||||
type Row = {
|
||||
isConnected: boolean
|
||||
children: Row[]
|
||||
getBoundingClientRect: () => { top: number; bottom: number }
|
||||
}
|
||||
|
||||
/** A row 100 tall whose top is `tops[0]` when first measured and `tops[1]` from then on. */
|
||||
function row(tops: number[], children: Row[] = []): Row {
|
||||
const answers = [...tops]
|
||||
return {
|
||||
isConnected: true,
|
||||
children,
|
||||
getBoundingClientRect: () => {
|
||||
const top = answers.length > 1 ? (answers.shift() ?? 0) : answers[0]
|
||||
return { top, bottom: top + 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A 600 tall view, 40 from the top of its content, over the given header block and list rows. */
|
||||
function scrollerOver(headerRows: Row[], listRows: Row[]) {
|
||||
const header = row([-40], headerRows)
|
||||
const heights = [1_000, 3_500]
|
||||
const node = {
|
||||
scrollTop: 40,
|
||||
// Grows by 2,500 where 2,000 lands above the reader: live output lands below in the same update.
|
||||
get scrollHeight(): number {
|
||||
return heights.length > 1 ? (heights.shift() ?? 0) : heights[0]
|
||||
},
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 600 }),
|
||||
firstElementChild: row([-40], [header, ...listRows])
|
||||
}
|
||||
return { node, listRef: { current: { getScrollableNode: () => node } } }
|
||||
}
|
||||
|
||||
describe('MobileNativeChatScrollHold', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
async function show(headKey: string, listRef: ReturnType<typeof scrollerOver>['listRef']) {
|
||||
const element = createElement(MobileNativeChatScrollHold, { headKey, listRef })
|
||||
await act(async () => {
|
||||
if (renderer) {
|
||||
renderer.update(element)
|
||||
} else {
|
||||
renderer = create(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
platform.OS = 'web'
|
||||
})
|
||||
|
||||
it('moves by what landed above the reader, not by live output landing below it too', async () => {
|
||||
const onScreen = row([300, 2_300])
|
||||
const { node, listRef } = scrollerOver([], [row([100, 2_100]), onScreen, row([700, 3_200])])
|
||||
await show('a', listRef)
|
||||
|
||||
await show('older', listRef)
|
||||
|
||||
expect(node.scrollTop).toBe(2_040)
|
||||
})
|
||||
|
||||
it('holds a reader who is inside the loaded history, above the list’s own rows', async () => {
|
||||
const control = row([-40])
|
||||
const { node, listRef } = scrollerOver([control, row([200, 2_200])], [row([900, 2_900])])
|
||||
await show('a', listRef)
|
||||
|
||||
await show('older', listRef)
|
||||
|
||||
expect(node.scrollTop).toBe(2_040)
|
||||
})
|
||||
|
||||
it('leaves the view alone when the row it was holding is gone', async () => {
|
||||
const onScreen = row([300, 2_300])
|
||||
const { node, listRef } = scrollerOver([], [onScreen])
|
||||
await show('a', listRef)
|
||||
onScreen.isConnected = false
|
||||
|
||||
await show('older', listRef)
|
||||
|
||||
expect(node.scrollTop).toBe(40)
|
||||
})
|
||||
|
||||
it('leaves the view alone while the first row is the same', async () => {
|
||||
const { node, listRef } = scrollerOver([], [row([300, 2_300])])
|
||||
await show('a', listRef)
|
||||
|
||||
await show('a', listRef)
|
||||
|
||||
expect(node.scrollTop).toBe(40)
|
||||
})
|
||||
|
||||
it('does nothing in a native build, where the list holds its own place', async () => {
|
||||
platform.OS = 'ios'
|
||||
const { node, listRef } = scrollerOver([], [row([300, 2_300])])
|
||||
await show('a', listRef)
|
||||
|
||||
await show('older', listRef)
|
||||
|
||||
expect(node.scrollTop).toBe(40)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +1,268 @@
|
||||
import { Component, type RefObject } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion'
|
||||
import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import {
|
||||
mobileNativeChatReadingParts,
|
||||
mobileNativeChatScrollRootKey,
|
||||
type MobileNativeChatScrollRoots
|
||||
} from './mobile-native-chat-scroll-roots'
|
||||
|
||||
type Box = { top: number; bottom: number }
|
||||
|
||||
/** The parts of a DOM element this reads; on the page the list's scrollable node is one. */
|
||||
type RowElement = {
|
||||
isConnected: boolean
|
||||
type Tagged = {
|
||||
getBoundingClientRect: () => Box
|
||||
children: ArrayLike<RowElement>
|
||||
}
|
||||
type Scroller = {
|
||||
scrollTop: number
|
||||
getBoundingClientRect: () => Box
|
||||
firstElementChild: RowElement | null
|
||||
getAttribute: (name: string) => string | null
|
||||
querySelectorAll: (selector: string) => ArrayLike<Tagged>
|
||||
}
|
||||
type Scroller = Tagged & { scrollTop: number; scrollHeight: number; clientHeight: number }
|
||||
export type MobileNativeChatScrollNode = Scroller
|
||||
|
||||
type Held = { row: RowElement; top: number }
|
||||
/** Part of what the reader sees: a tool line or its run (`member`), else a row; where it was, and
|
||||
* the message that placed its row. */
|
||||
type Unit = { key: string; top: number; member?: string; placed: string }
|
||||
|
||||
type Props = {
|
||||
/** The first row's key: a change in it is rows changing above the reader. */
|
||||
headKey: string | undefined
|
||||
listRef: RefObject<{ getScrollableNode: () => Scroller | null | undefined } | null>
|
||||
children?: React.ReactNode
|
||||
roots?: MobileNativeChatScrollRoots
|
||||
runPlacements?: ReadonlyMap<string, string>
|
||||
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'current' | 'placement'>
|
||||
/** How rows draw (tools shown, text size); a change regroups and re-lays them like a new source. */
|
||||
layoutKey?: string
|
||||
/** Whether the reader follows the end, which then owns the scroll. */
|
||||
isFollowing: () => boolean
|
||||
/** On the page, the list makes the correction (`keepInPlace`), mid-scroll only for a regroup. */
|
||||
listRef: RefObject<{
|
||||
getScrollableNode: () => Scroller | null | undefined
|
||||
keepInPlace?: (drift: number, regrouped: boolean) => void
|
||||
holdPlace?: (on: boolean) => void
|
||||
} | null>
|
||||
}
|
||||
|
||||
/** The lowest row on screen. Why the lowest: the "Load earlier" control stays at the top while
|
||||
* history lands under it, so the topmost element does not move with the rows. */
|
||||
function lowestRowOnScreen(scroller: Scroller): Held | null {
|
||||
const content = scroller.firstElementChild
|
||||
if (!content) {
|
||||
return null
|
||||
const ROW = 'chat-row:'
|
||||
const MEMBER = 'chat-member:'
|
||||
|
||||
/** A message row's `testID`, which the page draws as `data-testid`. It sets message rows apart from
|
||||
* the "Load earlier" control and the live status line, which don't move with the messages. */
|
||||
export function mobileNativeChatRowId(key: string): string {
|
||||
return `${ROW}${key}`
|
||||
}
|
||||
|
||||
/** A tool line's `testID`, by its line identity, or the run's, inside a row. */
|
||||
export function mobileNativeChatMemberId(id: string): string {
|
||||
return `${MEMBER}${id}`
|
||||
}
|
||||
|
||||
/** Tagged elements in document order; ids are compared, never put in a selector. */
|
||||
function tagged(node: Tagged, prefix: string): { id: string; node: Tagged }[] {
|
||||
return Array.from(node.querySelectorAll(`[data-testid^="${prefix}"]`), (element) => ({
|
||||
id: element.getAttribute('data-testid')?.slice(prefix.length) ?? '',
|
||||
node: element
|
||||
}))
|
||||
}
|
||||
|
||||
function onScreen(box: Box, view: Box): boolean {
|
||||
return box.bottom > view.top && box.top < view.bottom
|
||||
}
|
||||
|
||||
/** A row's tool lines on screen, else its run when the run's top is (below that top, lines can join
|
||||
* or leave), else the row itself. */
|
||||
function unitsOf(row: Tagged, view: Box, self: Unit): Unit[] {
|
||||
const shown: Unit[] = []
|
||||
const parts =
|
||||
typeof Element !== 'undefined' && row instanceof Element
|
||||
? mobileNativeChatReadingParts(row).map((node) => ({
|
||||
node,
|
||||
id:
|
||||
mobileNativeChatScrollRootKey(node) !== undefined
|
||||
? `root:${mobileNativeChatScrollRootKey(node)}`
|
||||
: (node.getAttribute('data-testid')?.slice(MEMBER.length) ?? '')
|
||||
}))
|
||||
: tagged(row, MEMBER)
|
||||
for (const { id, node } of parts) {
|
||||
const box = node.getBoundingClientRect()
|
||||
if (box.top >= view.bottom) {
|
||||
break
|
||||
}
|
||||
if (id === 'run' ? box.top >= view.top : onScreen(box, view)) {
|
||||
shown.push({ ...self, top: box.top, member: id })
|
||||
}
|
||||
}
|
||||
const lines = shown.filter((unit) => unit.member !== 'run')
|
||||
return lines.length > 0 ? lines : [shown[0] ?? self]
|
||||
}
|
||||
|
||||
/** What the reader is reading, in page order. */
|
||||
function placeOnScreen(scroller: Scroller, { placement }: Props['rowKeys']): Unit[] {
|
||||
const view = scroller.getBoundingClientRect()
|
||||
// The list's own rows, then the rows of the header block that precedes them.
|
||||
const header = content.children[0]
|
||||
for (const rows of [content.children, header?.children ?? []]) {
|
||||
for (let at = rows.length - 1; at >= 0; at -= 1) {
|
||||
const box = rows[at].getBoundingClientRect()
|
||||
if (box.top < view.bottom && box.bottom > view.top && rows[at] !== header) {
|
||||
return { row: rows[at], top: box.top }
|
||||
const rows = tagged(scroller, ROW)
|
||||
// Rows in normal flow have ordered bottoms, so the first one on screen is found without
|
||||
// measuring the up to 2,000 above it.
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2)
|
||||
if (rows[middle]!.node.getBoundingClientRect().bottom <= view.top) {
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle
|
||||
}
|
||||
}
|
||||
let end = low
|
||||
while (end < rows.length && rows[end]!.node.getBoundingClientRect().top < view.bottom) {
|
||||
end += 1
|
||||
}
|
||||
const self = ({ id, node }: { id: string; node: Tagged }): Unit => {
|
||||
return { key: id, top: node.getBoundingClientRect().top, placed: placement(id) }
|
||||
}
|
||||
const units = rows.slice(low, end).flatMap((row) => unitsOf(row.node, view, self(row)))
|
||||
// The nearest rows off screen stand in when the update takes away all that was on it.
|
||||
return units.concat([rows[end], rows[low - 1]].flatMap((row) => (row ? [self(row)] : [])))
|
||||
}
|
||||
|
||||
/** Where a held part is drawn now, if the update kept it. A line in the row now drawing its old
|
||||
* row's messages (an orphan result's line is its call's once paired); a run only in the row that
|
||||
* kept its key; a row only while the same message places it, since a group keeps its key when the
|
||||
* fold moves it. */
|
||||
function drawn(
|
||||
unit: Unit,
|
||||
rows: Map<string, Tagged>,
|
||||
keys: Props['rowKeys'],
|
||||
roots?: MobileNativeChatScrollRoots
|
||||
): Tagged | undefined {
|
||||
const { key, member } = unit
|
||||
if (member?.startsWith('root:')) {
|
||||
return roots?.frame(member.slice(5))
|
||||
}
|
||||
if (member === undefined) {
|
||||
return keys.placement(key) === unit.placed ? rows.get(key) : undefined
|
||||
}
|
||||
const row = rows.get(member === 'run' ? key : (keys.current(key) ?? ''))
|
||||
const paired = member.startsWith('result:') ? `call:${member.slice('result:'.length)}` : member
|
||||
return row && tagged(row, MEMBER).find((m) => m.id === member || m.id === paired)?.node
|
||||
}
|
||||
|
||||
/** The update moves what the reader reads to another row (a line to another group, a row placed by
|
||||
* another message): the browser's anchoring loses that node, so the hold puts it back. */
|
||||
function moves(
|
||||
unit: Unit | undefined,
|
||||
keys: Props['rowKeys'],
|
||||
placements?: ReadonlyMap<string, string>,
|
||||
roots?: MobileNativeChatScrollRoots
|
||||
): boolean {
|
||||
if (!unit) {
|
||||
return false
|
||||
}
|
||||
if (unit.member === undefined) {
|
||||
return keys.placement(unit.key) !== unit.placed
|
||||
}
|
||||
if (unit.member.startsWith('root:')) {
|
||||
const owner = roots?.owner(unit.member.slice(5))
|
||||
const now = owner === undefined ? undefined : placements?.get(owner)
|
||||
return now !== undefined && now !== unit.placed
|
||||
}
|
||||
// An orphan result's line goes to its call's row once the call lands, and its old row stays.
|
||||
return (
|
||||
unit.member.startsWith('result:') ||
|
||||
(unit.member !== 'run' && keys.current(unit.key) !== unit.key)
|
||||
)
|
||||
}
|
||||
|
||||
/** iOS is still bouncing the list past an end. A write then lands while WebKit owns the scroll, and
|
||||
* the list stops taking the reader's scrolls until they leave the chat. */
|
||||
function overscrolled({ scrollTop, scrollHeight, clientHeight }: Scroller): boolean {
|
||||
return scrollTop < 0 || scrollTop > scrollHeight - clientHeight + 1
|
||||
}
|
||||
|
||||
/** Keeps a parked reader's place on the page when the source window changes above them (an older
|
||||
* page, a cap trim) or the rows regroup (Tools, text size): the first part of what they were reading that the update kept goes back where
|
||||
* it was. Where the browser has scroll anchoring it keeps the place through ordinary changes, so the
|
||||
* hold runs only for a regroup or a move to another row, with the browser's anchoring off from
|
||||
* before the update until its correction. A following reader is left to the follow owner, which
|
||||
* holds the end. A class because only `getSnapshotBeforeUpdate` sees the rows before the update is
|
||||
* in the document. A native list holds its own place (`maintainVisibleContentPosition`). */
|
||||
export class MobileNativeChatScrollHold extends Component<Props> {
|
||||
getSnapshotBeforeUpdate(previous: Props): Unit[] | null {
|
||||
const regrouped = previous.layoutKey !== this.props.layoutKey
|
||||
const moved = new Set(
|
||||
Array.from(previous.runPlacements ?? []).flatMap(([owner, row]) =>
|
||||
this.props.runPlacements?.get(owner) !== row ? [owner] : []
|
||||
)
|
||||
)
|
||||
if (Platform.OS === 'web' && previous.rowKeys.scope === this.props.rowKeys.scope) {
|
||||
this.props.roots?.capture(moved, regrouped)
|
||||
}
|
||||
// Another chat opens at its end, which the follow owner resets only after this reads it.
|
||||
if (
|
||||
Platform.OS !== 'web' ||
|
||||
previous.rowKeys.scope !== this.props.rowKeys.scope ||
|
||||
(previous.rowKeys.sourceHead === this.props.rowKeys.sourceHead &&
|
||||
!regrouped &&
|
||||
moved.size === 0) ||
|
||||
this.props.isFollowing()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const list = this.props.listRef.current
|
||||
const scroller = list?.getScrollableNode()
|
||||
const held =
|
||||
scroller && !overscrolled(scroller) ? placeOnScreen(scroller, previous.rowKeys) : null
|
||||
if (
|
||||
!held ||
|
||||
(!scrollWritesStopMomentum() &&
|
||||
!regrouped &&
|
||||
!moves(held[0], this.props.rowKeys, this.props.runPlacements, this.props.roots))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
list?.holdPlace?.(true)
|
||||
return held
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.roots?.restoreAll()
|
||||
}
|
||||
|
||||
componentDidUpdate(previous: Props, _state: unknown, held: Unit[] | null): void {
|
||||
try {
|
||||
const regrouped = previous.layoutKey !== this.props.layoutKey
|
||||
this.props.roots?.restoreAll(regrouped, false)
|
||||
if (held) {
|
||||
this.restore(held, regrouped)
|
||||
}
|
||||
} finally {
|
||||
this.props.roots?.releaseHold()
|
||||
// Whatever the correction did, the scroll goes back to its owner.
|
||||
if (held) {
|
||||
this.props.listRef.current?.holdPlace?.(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Keeps the page's view on the same rows when rows above it change, by putting a row that was on
|
||||
* screen back where it was. A class because only `getSnapshotBeforeUpdate` sees that row before
|
||||
* the new ones are in the document. A native list holds its own place
|
||||
* (`maintainVisibleContentPosition`). */
|
||||
export class MobileNativeChatScrollHold extends Component<Props> {
|
||||
getSnapshotBeforeUpdate(previous: Props): Held | null {
|
||||
if (Platform.OS !== 'web' || previous.headKey === this.props.headKey) {
|
||||
return null
|
||||
private restore(held: Unit[], regrouped: boolean): void {
|
||||
const list = this.props.listRef.current
|
||||
const scroller = held.length ? list?.getScrollableNode() : null
|
||||
if (!scroller) {
|
||||
return
|
||||
}
|
||||
const scroller = this.props.listRef.current?.getScrollableNode()
|
||||
return scroller ? lowestRowOnScreen(scroller) : null
|
||||
}
|
||||
|
||||
componentDidUpdate(_previous: Props, _state: unknown, held: Held | null): void {
|
||||
const scroller = held?.row.isConnected ? this.props.listRef.current?.getScrollableNode() : null
|
||||
if (held && scroller) {
|
||||
scroller.scrollTop += held.row.getBoundingClientRect().top - held.top
|
||||
const rows = new Map(tagged(scroller, ROW).map(({ id, node }) => [id, node]))
|
||||
for (const unit of held) {
|
||||
const node = drawn(unit, rows, this.props.rowKeys, this.props.roots)
|
||||
if (node) {
|
||||
const drift = node.getBoundingClientRect().top - unit.top
|
||||
// Sub-pixel drift is layout noise; a write would stop iOS momentum for nothing.
|
||||
if (Math.abs(drift) >= 0.5 && list?.keepInPlace) {
|
||||
list.keepInPlace(drift, regrouped)
|
||||
} else if (Math.abs(drift) >= 0.5) {
|
||||
scroller.scrollTop += drift
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(): null {
|
||||
return null
|
||||
render(): React.ReactNode {
|
||||
return this.props.children ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ChevronRight, Circle, CircleCheck, CircleDot, ListChecks } from 'lucide-react-native'
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail'
|
||||
import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles'
|
||||
import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store'
|
||||
|
||||
const STATUS_WORD: Record<NativeChatTask['status'], string> = {
|
||||
completed: NATIVE_CHAT_TASK_LIST_COPY.completed,
|
||||
@@ -83,13 +83,21 @@ function Checklist({ list, fontScale }: { list: TaskList; fontScale: number }) {
|
||||
}
|
||||
|
||||
/** The whole list behind a disclosure, under a list of what changed. */
|
||||
function FullList({ list, fontScale }: { list: TaskList; fontScale: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
function FullList({
|
||||
list,
|
||||
fontScale,
|
||||
disclosureKey
|
||||
}: {
|
||||
list: TaskList
|
||||
fontScale: number
|
||||
disclosureKey?: string
|
||||
}) {
|
||||
const [open, setOpen] = useMobileNativeChatDisclosure(disclosureKey, false)
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={styles.taskDisclosure}
|
||||
onPress={() => setOpen((value) => !value)}
|
||||
onPress={() => setOpen(!open)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: open }}
|
||||
>
|
||||
@@ -119,11 +127,13 @@ function OpenedChecklist({ list, fontScale }: { list: TaskList; fontScale: numbe
|
||||
export function MobileNativeChatTaskList({
|
||||
list,
|
||||
previous,
|
||||
fontScale
|
||||
fontScale,
|
||||
disclosureKey
|
||||
}: {
|
||||
list: TaskList
|
||||
previous?: TaskList
|
||||
fontScale: number
|
||||
disclosureKey?: string
|
||||
}): React.JSX.Element {
|
||||
const completed = list.tasks.filter((task) => task.status === 'completed').length
|
||||
const changes = previous ? diffNativeChatTaskLists(previous, list) : null
|
||||
@@ -163,7 +173,7 @@ export function MobileNativeChatTaskList({
|
||||
) : (
|
||||
<Text style={[styles.lineMeta, type.meta]}>{NATIVE_CHAT_TASK_LIST_COPY.unchanged}</Text>
|
||||
)}
|
||||
<FullList list={list} fontScale={fontScale} />
|
||||
<FullList list={list} fontScale={fontScale} disclosureKey={disclosureKey} />
|
||||
</>
|
||||
) : (
|
||||
<Checklist list={list} fontScale={fontScale} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native'
|
||||
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'
|
||||
@@ -92,7 +93,9 @@ function ToolLineDetail({
|
||||
fullCommand,
|
||||
commandLanguage,
|
||||
results,
|
||||
fontScale
|
||||
fontScale,
|
||||
disclosureKey,
|
||||
memberId
|
||||
}: {
|
||||
call: NativeChatToolCallBlock | undefined
|
||||
body: NativeChatToolResultBlock | undefined
|
||||
@@ -100,6 +103,8 @@ function ToolLineDetail({
|
||||
commandLanguage: string | null
|
||||
results: NativeChatWebSearchResult[]
|
||||
fontScale: number
|
||||
disclosureKey?: string
|
||||
memberId?: string
|
||||
}): React.JSX.Element {
|
||||
const diff = call
|
||||
? diffFromToolCall(call.name, call.input)
|
||||
@@ -120,7 +125,9 @@ function ToolLineDetail({
|
||||
{results.length > 0 ? <SearchResults results={results} fontScale={fontScale} /> : null}
|
||||
{diff ? <DiffView lines={diff} fontScale={fontScale} /> : null}
|
||||
{!diff && fullCommand ? (
|
||||
<ScrollView
|
||||
<MobileNativeChatOwnedScrollView
|
||||
rootIdentity={`${memberId ?? call?.callId ?? 'detail'}:command`}
|
||||
positionKey={`${disclosureKey ?? call?.callId ?? 'detail'}:command`}
|
||||
style={[styles.codeBox, styles.codeScroll]}
|
||||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -128,10 +135,12 @@ function ToolLineDetail({
|
||||
<Text selectable={INLINE_TEXT_SELECTION} style={[styles.codeText, type]}>
|
||||
{command ? <MobileSyntaxSegments segments={command} /> : fullCommand}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
) : null}
|
||||
{body ? (
|
||||
<ScrollView
|
||||
<MobileNativeChatOwnedScrollView
|
||||
rootIdentity={`${memberId ?? body?.callId ?? 'detail'}:output`}
|
||||
positionKey={`${disclosureKey ?? body?.callId ?? 'detail'}:output`}
|
||||
style={[styles.codeBox, styles.codeScroll]}
|
||||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -142,7 +151,7 @@ function ToolLineDetail({
|
||||
>
|
||||
{truncateToolDetail(body.output)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
@@ -151,6 +160,7 @@ function ToolLineDetail({
|
||||
/** 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,
|
||||
@@ -158,6 +168,8 @@ export function MobileNativeChatToolLine({
|
||||
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
|
||||
@@ -198,7 +210,7 @@ export function MobileNativeChatToolLine({
|
||||
const duration = call ? formatToolDuration(call.durationMs) : null
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View testID={memberId}>
|
||||
<Pressable
|
||||
style={styles.line}
|
||||
onPress={() => hasDetail && setExpanded(!expanded)}
|
||||
@@ -248,6 +260,8 @@ export function MobileNativeChatToolLine({
|
||||
</Pressable>
|
||||
{hasDetail && expanded ? (
|
||||
<ToolLineDetail
|
||||
memberId={memberId}
|
||||
disclosureKey={disclosureKey}
|
||||
call={call}
|
||||
body={body}
|
||||
fullCommand={fullCommand}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import {
|
||||
Animated,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Platform,
|
||||
type ScrollView,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
@@ -51,6 +52,13 @@ import {
|
||||
useMobileNativeChatDisclosure
|
||||
} from './mobile-native-chat-disclosure-store'
|
||||
import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles'
|
||||
import { mobileNativeChatMemberId } from './MobileNativeChatScrollHold'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
import type {
|
||||
MobileNativeChatToolRunAsides,
|
||||
MobileNativeChatToolRunProps
|
||||
} from './mobile-native-chat-tool-run-props'
|
||||
export type { MobileNativeChatToolRunAsides } from './mobile-native-chat-tool-run-props'
|
||||
|
||||
/** Desktop's 18rem: an opened run lists this much before it scrolls in place. */
|
||||
const MEMBERS_MIN_HEIGHT = 288
|
||||
@@ -94,13 +102,6 @@ function PulsingText({
|
||||
)
|
||||
}
|
||||
|
||||
/** Desktop parity: `NativeChatToolRunAsides`. Rows drawn among an open run's lines, each keyed:
|
||||
* before the block they are keyed by, or after the last. */
|
||||
export type MobileNativeChatToolRunAsides = {
|
||||
before: ReadonlyMap<NativeChatBlock, readonly React.JSX.Element[]>
|
||||
after: readonly React.JSX.Element[]
|
||||
}
|
||||
|
||||
/** Desktop parity: `NativeChatToolRunMembers`. An opened run's members in a bounded
|
||||
* box, so live work fills it instead of lengthening the chat. A live run opens on
|
||||
* what it is doing now and follows it while the reader stays at the end. */
|
||||
@@ -108,6 +109,7 @@ function ToolRunMembers({
|
||||
followKey,
|
||||
startsAtEnd,
|
||||
memoryKey,
|
||||
owner,
|
||||
children
|
||||
}: {
|
||||
/** How much work the run holds: a reader at the end follows it growing. Opening a detail
|
||||
@@ -116,10 +118,13 @@ function ToolRunMembers({
|
||||
startsAtEnd: boolean
|
||||
/** The run's disclosure key. The list recycles rows; the box comes back as the reader left it. */
|
||||
memoryKey?: string
|
||||
owner?: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const { height } = useWindowDimensions()
|
||||
const runScroll = useContext(MobileNativeChatDisclosureContext)?.runScroll
|
||||
const store = useContext(MobileNativeChatDisclosureContext)
|
||||
const runScroll = store?.runScroll
|
||||
const owned = Platform.OS === 'web' && store !== null
|
||||
const [remembered] = useState(() =>
|
||||
memoryKey === undefined ? undefined : runScroll?.get(memoryKey)
|
||||
)
|
||||
@@ -129,10 +134,10 @@ function ToolRunMembers({
|
||||
// Desktop's rule: follow when the work changes, and only if the reader was at the end before
|
||||
// it. Resolved here, with the update, so a later detail opening never inherits a follow.
|
||||
useLayoutEffect(() => {
|
||||
if (followsEnd.current) {
|
||||
if (!owned && followsEnd.current) {
|
||||
scroller.current?.scrollToEnd({ animated: false })
|
||||
}
|
||||
}, [followKey])
|
||||
}, [followKey, owned])
|
||||
const details = useRef(new Map<string, number>())
|
||||
const [openedHeight, setOpenedHeight] = useState(0)
|
||||
// Room for the list plus what the reader has opened in it, up to half the screen.
|
||||
@@ -152,7 +157,7 @@ function ToolRunMembers({
|
||||
const { contentOffset, contentSize, layoutMeasurement } = nativeEvent
|
||||
followsEnd.current =
|
||||
contentSize.height - contentOffset.y - layoutMeasurement.height <= FOLLOW_REARM_PX
|
||||
if (runScroll && memoryKey !== undefined) {
|
||||
if (!owned && runScroll && memoryKey !== undefined) {
|
||||
runScroll.delete(memoryKey)
|
||||
runScroll.set(memoryKey, { top: contentOffset.y, followsEnd: followsEnd.current })
|
||||
if (runScroll.size > MAX_DISCLOSURES) {
|
||||
@@ -165,14 +170,19 @@ function ToolRunMembers({
|
||||
}
|
||||
// The remembered offset needs the rows laid out first, which the box's first size reports.
|
||||
const onContentSizeChange = () => {
|
||||
if (pendingRestore.current && remembered) {
|
||||
if (!owned && pendingRestore.current && remembered) {
|
||||
pendingRestore.current = false
|
||||
scroller.current?.scrollTo({ y: remembered.top, animated: false })
|
||||
}
|
||||
}
|
||||
return (
|
||||
<MobileNativeChatOpenedDetailContext.Provider value={reportDetail}>
|
||||
<ScrollView
|
||||
<MobileNativeChatOwnedScrollView
|
||||
positionKey={memoryKey ?? `run:${owner ?? 'local'}`}
|
||||
rootIdentity={`run:${owner ?? 'local'}`}
|
||||
owner={owner}
|
||||
initialFollow={startsAtEnd}
|
||||
followKey={followKey}
|
||||
ref={scroller}
|
||||
style={[
|
||||
styles.members,
|
||||
@@ -186,7 +196,7 @@ function ToolRunMembers({
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
</MobileNativeChatOpenedDetailContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -206,28 +216,7 @@ export function ToolRun({
|
||||
previousUpdatePlan,
|
||||
fontScale = 1,
|
||||
onOpenFile
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
/** The last plan each task-list tool wrote before this run, so an update reads as its changes. */
|
||||
previousTodoWrite?: NativeChatToolCallBlock
|
||||
previousUpdatePlan?: NativeChatToolCallBlock
|
||||
defaultExpanded: boolean
|
||||
/** The row this run belongs to. The list recycles rows, so what the reader opened is
|
||||
* remembered somewhere that outlives the row. */
|
||||
disclosureId?: string
|
||||
/** Rows drawn among the run's lines, each before the block it is keyed by, or after the last. */
|
||||
asides?: MobileNativeChatToolRunAsides
|
||||
/** Every line's default: open under the Tools toggle. */
|
||||
expandChildren: boolean
|
||||
/** Structured lifecycle state; keeps an orphaned running call from spinning. */
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Whether this run is the working turn's last. Only that run is live. */
|
||||
trailing?: boolean
|
||||
/** The turn is blocked on the reader's answer, so its calls decide liveness. */
|
||||
awaitingAnswer?: boolean
|
||||
fontScale?: number
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
}: MobileNativeChatToolRunProps): React.JSX.Element {
|
||||
// A reader's choice belongs to the default it deviated from, so toggling the Tools
|
||||
// default back restores it.
|
||||
const runKey = disclosureId === undefined ? undefined : `run:${disclosureId}:${defaultExpanded}`
|
||||
@@ -271,7 +260,7 @@ export function ToolRun({
|
||||
const iconSize = 15 * fontScale
|
||||
|
||||
return (
|
||||
<View style={styles.run}>
|
||||
<View style={styles.run} testID={mobileNativeChatMemberId('run')}>
|
||||
{/* One element for the run's whole life: live and settled are states of this
|
||||
button, not two buttons. */}
|
||||
<Pressable
|
||||
@@ -311,6 +300,7 @@ export function ToolRun({
|
||||
followKey={blocks.length + asideCount}
|
||||
startsAtEnd={live}
|
||||
memoryKey={runKey}
|
||||
owner={disclosureId}
|
||||
>
|
||||
<ToolRunMemberList
|
||||
blocks={blocks}
|
||||
@@ -360,26 +350,40 @@ function ToolRunMemberList({
|
||||
const { editCards, consumedResults } = useMemo(() => buildEditCards(blocks), [blocks])
|
||||
const { resultByCall, pairedResults } = useMemo(() => pairNativeChatToolResults(blocks), [blocks])
|
||||
const seen = new Map<string, number>()
|
||||
const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => {
|
||||
// Each drawn member's line identity: the page's place-hold finds it by this, and a card keyed by
|
||||
// it keeps its state when older calls join the run above it.
|
||||
const memberSeen = new Map<string, number>()
|
||||
const memberIdentity = (block: NativeChatBlock) =>
|
||||
nativeChatToolLineIdentity(block, memberSeen, true)
|
||||
const member = (block: NativeChatBlock): React.ReactNode => {
|
||||
const taskList = taskLists.rows.get(block)
|
||||
if (taskList) {
|
||||
const id = memberIdentity(block)
|
||||
return (
|
||||
<MobileNativeChatTaskList key={`tasks:${blockIndex}`} {...taskList} fontScale={fontScale} />
|
||||
<View key={`tasks:${id}`} testID={mobileNativeChatMemberId(id)}>
|
||||
<MobileNativeChatTaskList
|
||||
{...taskList}
|
||||
fontScale={fontScale}
|
||||
disclosureKey={disclosureId === undefined ? undefined : `tasks:${disclosureId}:${id}`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const edit = editCards.get(block)
|
||||
if (edit) {
|
||||
const id = memberIdentity(block)
|
||||
return (
|
||||
<View key={`edit:${edit.key}`}>
|
||||
<View key={`edit:${id}`} testID={mobileNativeChatMemberId(id)}>
|
||||
{edit.files.map((file, fileIndex) => (
|
||||
<MobileNativeChatDiffCard
|
||||
key={`${edit.key}:${fileIndex}`}
|
||||
key={`${id}:${fileIndex}`}
|
||||
file={file}
|
||||
rootIdentity={`diff:${id}:${fileIndex}`}
|
||||
defaultExpanded={expandChildren}
|
||||
disclosureKey={
|
||||
disclosureId === undefined
|
||||
? undefined
|
||||
: `diff:${disclosureId}:${expandChildren}:${edit.key}:${fileIndex}`
|
||||
: `diff:${disclosureId}:${expandChildren}:${id}:${fileIndex}`
|
||||
}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
@@ -401,6 +405,7 @@ function ToolRunMemberList({
|
||||
return (
|
||||
<MobileNativeChatToolLine
|
||||
key={lineIdentity}
|
||||
memberId={mobileNativeChatMemberId(memberIdentity(block))}
|
||||
block={block}
|
||||
result={block.type === 'tool-call' ? resultByCall.get(block) : undefined}
|
||||
defaultExpanded={expandChildren}
|
||||
@@ -418,10 +423,7 @@ function ToolRunMemberList({
|
||||
return (
|
||||
<>
|
||||
{/* One flat keyed list with or without asides, so a row gaining its first never remounts a line. */}
|
||||
{blocks.flatMap((block, blockIndex) => [
|
||||
...(asides?.before.get(block) ?? []),
|
||||
member(block, blockIndex)
|
||||
])}
|
||||
{blocks.flatMap((block) => [...(asides?.before.get(block) ?? []), member(block)])}
|
||||
{asides?.after}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -75,7 +75,8 @@ export function installChatViewHooks(): void {
|
||||
renderer = null
|
||||
scrollToEnd.mockReset()
|
||||
scrollToOffset.mockReset()
|
||||
pageScroller.scrollHeight = 0
|
||||
Object.assign(pageScroller, { scrollTop: 0, scrollHeight: 0, clientHeight: 500 })
|
||||
pageScroller.style.overflowAnchor = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
}
|
||||
@@ -141,6 +142,17 @@ export async function pressSend(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** What the list reports from a scroll, drag or momentum event, in a 500-tall viewport. */
|
||||
export function scrollEvent(y: number, contentHeight: number) {
|
||||
return {
|
||||
nativeEvent: {
|
||||
contentOffset: { y },
|
||||
contentSize: { height: contentHeight },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollAwayFromTail(): Promise<void> {
|
||||
await act(async () => {
|
||||
list().props.onScrollBeginDrag?.({})
|
||||
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
installChatViewHooks,
|
||||
assistantTurn,
|
||||
hostType,
|
||||
list,
|
||||
pageScroller,
|
||||
platform,
|
||||
render,
|
||||
root,
|
||||
scrollEvent,
|
||||
scrollToEnd,
|
||||
scrollToOffset,
|
||||
update
|
||||
@@ -132,8 +134,30 @@ describe('MobileNativeChatView: history paging and anchoring', () => {
|
||||
})
|
||||
|
||||
describe('on the page, where the list reports no drag or momentum', () => {
|
||||
const observers = new Set<(entries: unknown[]) => void>()
|
||||
// The browser's resize step, before paint; RN Web's size report comes a task later.
|
||||
const resized = () => act(() => observers.forEach((deliver) => deliver([])))
|
||||
const page = () => root().find((node) => hostType(node) === 'ScrollView')
|
||||
const loadEarlier = () =>
|
||||
root().find(
|
||||
(node) =>
|
||||
hostType(node) === 'Pressable' &&
|
||||
node.findAll((text) => text.props.children === 'Load earlier messages').length > 0
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
platform.OS = 'web'
|
||||
scrollToOffset.mockImplementation((to: { offset: number }) => {
|
||||
pageScroller.scrollTop = to.offset
|
||||
})
|
||||
observers.clear()
|
||||
vi.stubGlobal('ResizeObserver', function (deliver: (entries: unknown[]) => void) {
|
||||
return {
|
||||
observe: () => observers.add(deliver),
|
||||
unobserve: () => {},
|
||||
disconnect: () => observers.delete(deliver)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -141,77 +165,199 @@ describe('MobileNativeChatView: history paging and anchoring', () => {
|
||||
})
|
||||
|
||||
function scrolledTo(y: number, contentHeight: number) {
|
||||
return {
|
||||
nativeEvent: {
|
||||
contentOffset: { y },
|
||||
contentSize: { height: contentHeight },
|
||||
layoutMeasurement: { height: 500 }
|
||||
}
|
||||
}
|
||||
Object.assign(pageScroller, { scrollTop: y, scrollHeight: contentHeight })
|
||||
return scrollEvent(y, contentHeight)
|
||||
}
|
||||
|
||||
it('stops following when the reader scrolls up, and resumes at the end', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
// RN Web's report and the scroller's own event, for one position.
|
||||
function scrollTo(y: number, contentHeight: number) {
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
page().props.onScroll(scrolledTo(y, contentHeight))
|
||||
pageScroller.scroll()
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
}
|
||||
|
||||
// No drag event arrives on the page: the move up is all there is to go on.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(300, 1_000))
|
||||
list().props.onContentSizeChange(320, 1_200)
|
||||
})
|
||||
expect(scrollToOffset).not.toHaveBeenCalled()
|
||||
it('holds a following reader at the live end through growth and a smaller viewport', async () => {
|
||||
const latest = [assistantTurn('a1', 'Latest')]
|
||||
const landed = [assistantTurn('a0', 'Older'), ...latest]
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: latest, hasMore: true, onLoadEarlier })
|
||||
scrollTo(500, 1_000)
|
||||
|
||||
// Older rows land; the observer pins before paint.
|
||||
act(() => loadEarlier().props.onPress())
|
||||
await update({ folded: landed, hasMore: true, onLoadEarlier })
|
||||
pageScroller.scrollHeight = 5_000
|
||||
resized()
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenCalledOnce()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_500 })
|
||||
|
||||
// The pin's own move, short of a reply that grew meanwhile, is not the reader's.
|
||||
scrollTo(4_500, 5_400)
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 })
|
||||
|
||||
// The browser clamping the scroll to a shorter end is layout's, not the reader's: it still
|
||||
// follows, and the next growth pins.
|
||||
scrollTo(3_500, 4_000)
|
||||
pageScroller.scrollHeight = 5_400
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenCalledTimes(3)
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 4_900 })
|
||||
scrollTo(4_900, 5_400)
|
||||
|
||||
// The reader's wheel moves it up: it leaves the end.
|
||||
pageScroller.input('wheel')
|
||||
scrollTo(3_000, 5_400)
|
||||
pageScroller.scrollHeight = 6_000
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenCalledTimes(3)
|
||||
expect(
|
||||
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
|
||||
).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_200))
|
||||
list().props.onContentSizeChange(320, 1_400)
|
||||
})
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_400 })
|
||||
// Back at the end it follows again, and a smaller viewport pins to the live end.
|
||||
scrollTo(5_500, 6_000)
|
||||
pageScroller.scrollHeight = 6_400
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 5_900 })
|
||||
pageScroller.clientHeight = 300
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 6_100 })
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not take its own pin, landing short of a grown end, for the reader', async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')] })
|
||||
act(() => {
|
||||
list().props.onContentSizeChange(320, 1_000)
|
||||
list().props.onScroll(scrolledTo(500, 1_000))
|
||||
})
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
// The pin reached the end it was sent to; the reply had grown 400 past it by then.
|
||||
act(() => {
|
||||
list().props.onScroll(scrolledTo(700, 1_600))
|
||||
list().props.onContentSizeChange(320, 1_600)
|
||||
})
|
||||
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1_600 })
|
||||
it('resumes an expired downward fling near the end, preserving small upward detachment', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(10_000)
|
||||
vi.stubGlobal('navigator', { userAgent: 'Chrome' })
|
||||
const browserHolds = () => pageScroller.style.overflowAnchor === 'auto'
|
||||
await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) })
|
||||
scrollTo(500, 1_000)
|
||||
resized()
|
||||
pageScroller.input('wheel')
|
||||
scrollTo(497, 1_000)
|
||||
// Expired input and geometry away from the end cannot detach or attach by themselves.
|
||||
now.mockReturnValue(11_100)
|
||||
pageScroller.scrollHeight = 1_200
|
||||
resized()
|
||||
expect(browserHolds()).toBe(true)
|
||||
scrollTo(632, 1_200)
|
||||
expect(browserHolds()).toBe(false)
|
||||
pageScroller.scrollHeight = 1_300
|
||||
resized()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 800 })
|
||||
scrollTo(800, 1_300) // Deliver the pin's own scroll before the next reader movement.
|
||||
// The first upward movement can arrive in resize before its scroll event.
|
||||
pageScroller.input('wheel')
|
||||
pageScroller.scrollTop = 780
|
||||
resized()
|
||||
expect(browserHolds()).toBe(true)
|
||||
scrollTo(799, 1_300)
|
||||
expect(browserHolds()).toBe(false)
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it("opens another chat at its measured end, not the last chat's", async () => {
|
||||
it('detaches a following reader who drags up slowly, one pixel per scroll event', async () => {
|
||||
// iOS, where it happened: WebKit has no anchoring, so every move that isn't a clamp is the reader's.
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1'
|
||||
})
|
||||
await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) })
|
||||
scrollTo(500, 1_000)
|
||||
resized()
|
||||
const pins = scrollToOffset.mock.calls.length
|
||||
// Each step's frame reconciles what it drew, as the browser's resize step does.
|
||||
for (let top = 499; top >= 470; top -= 1) {
|
||||
scrollTo(top, 1_000)
|
||||
resized()
|
||||
}
|
||||
// A reply lands: the reader who dragged 30px up is not pulled back to the end.
|
||||
pageScroller.scrollHeight = 1_063
|
||||
resized()
|
||||
expect(scrollToOffset.mock.calls.length).toBe(pins)
|
||||
})
|
||||
|
||||
it('resumes following after a slow one-pixel return to the end on Safari', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1'
|
||||
})
|
||||
await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) })
|
||||
scrollTo(500, 1_000)
|
||||
resized()
|
||||
// Deliver the initial pin's own scroll before the reader moves.
|
||||
scrollTo(500, 1_000)
|
||||
scrollTo(470, 1_000)
|
||||
resized()
|
||||
const pins = scrollToOffset.mock.calls.length
|
||||
|
||||
for (let top = 471; top <= 500; top += 1) {
|
||||
scrollTo(top, 1_000)
|
||||
resized()
|
||||
expect(scrollToOffset.mock.calls.length).toBe(pins)
|
||||
}
|
||||
|
||||
pageScroller.scrollHeight = 1_063
|
||||
resized()
|
||||
expect(scrollToOffset.mock.calls.length).toBe(pins + 1)
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 563 })
|
||||
expect(pageScroller.scrollTop).toBe(563)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'leaves a parked reader parked when a collapse below them clamps the page onto its end (finger down: %s)',
|
||||
async (fingerDown) => {
|
||||
await render({ folded: ['a1', 'a2', 'a3'].map((id) => assistantTurn(id, id)) })
|
||||
scrollTo(500, 1_000)
|
||||
resized()
|
||||
// Parked 29 px short of the end by the reader's own wheel.
|
||||
pageScroller.input('wheel')
|
||||
scrollTo(471, 1_000)
|
||||
const pins = scrollToOffset.mock.calls.length
|
||||
|
||||
// A run below collapses: the browser clamps onto the shorter end, possibly under the tap.
|
||||
if (fingerDown) {
|
||||
pageScroller.input('touchstart')
|
||||
}
|
||||
scrollTo(200, 700)
|
||||
resized()
|
||||
if (fingerDown) {
|
||||
pageScroller.input('touchend')
|
||||
}
|
||||
// A reply lands. Main leaves the reader where the clamp put them; so does the page.
|
||||
pageScroller.scrollHeight = 763
|
||||
resized()
|
||||
expect(scrollToOffset.mock.calls.length).toBe(pins)
|
||||
}
|
||||
)
|
||||
|
||||
it("opens another chat at its live end, not the last chat's", async () => {
|
||||
await render({ folded: [assistantTurn('a1', 'Chat A')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1_000))
|
||||
scrollTo(500, 1_000)
|
||||
// The next chat is taller; its size report hasn't come yet.
|
||||
pageScroller.scrollHeight = 2_500
|
||||
scrollToEnd.mockClear()
|
||||
scrollToOffset.mockClear()
|
||||
|
||||
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
|
||||
|
||||
expect(scrollToEnd).not.toHaveBeenCalled()
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_500 })
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 2_000 })
|
||||
})
|
||||
|
||||
it('pages in history when the reader scrolls up to the top, not when a pin passes it', async () => {
|
||||
it('loads older history from the Load earlier control, not from reaching the top', async () => {
|
||||
const onLoadEarlier = vi.fn()
|
||||
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(40, 540)))
|
||||
// Down to the top and into iOS's bounce past it.
|
||||
act(() => {
|
||||
page().props.onScroll(scrolledTo(40, 540))
|
||||
page().props.onScroll(scrolledTo(-15, 540))
|
||||
})
|
||||
expect(onLoadEarlier).not.toHaveBeenCalled()
|
||||
|
||||
act(() => list().props.onScroll(scrolledTo(10, 540)))
|
||||
act(() => loadEarlier().props.onPress())
|
||||
expect(onLoadEarlier).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,29 @@ import { vi } from 'vitest'
|
||||
const scrollToEnd = vi.hoisted(() => vi.fn())
|
||||
const scrollToOffset = vi.hoisted(() => vi.fn())
|
||||
const platform = vi.hoisted(() => ({ OS: 'ios' }))
|
||||
const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 }))
|
||||
// The page list's live DOM geometry.
|
||||
const pageScroller = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Set<(event?: unknown) => void>>()
|
||||
return {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 500,
|
||||
clientWidth: 320,
|
||||
style: { overflowAnchor: '' },
|
||||
getInnerViewNode: () => ({}),
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 500 }),
|
||||
addEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||
listeners.set(type, (listeners.get(type) ?? new Set()).add(listener))
|
||||
},
|
||||
removeEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||
listeners.get(type)?.delete(listener)
|
||||
},
|
||||
// The browser's scroll event at the scroller's current position.
|
||||
scroll: () => listeners.get('scroll')?.forEach((listener) => listener()),
|
||||
// The reader's hand on the scroller, e.g. a wheel turn.
|
||||
input: (type: string) => listeners.get(type)?.forEach((listener) => listener())
|
||||
}
|
||||
})
|
||||
|
||||
export async function reactNativeStub() {
|
||||
const React = await import('react')
|
||||
@@ -16,6 +38,17 @@ export async function reactNativeStub() {
|
||||
}),
|
||||
Platform: platform,
|
||||
Pressable: 'Pressable',
|
||||
// The page's list scrolls through it.
|
||||
ScrollView: React.forwardRef((props, ref) => {
|
||||
const handle = {
|
||||
scrollTo: ({ y, animated }: { y: number; animated: boolean }) =>
|
||||
scrollToOffset({ animated, offset: y }),
|
||||
scrollToEnd,
|
||||
getScrollableNode: () => pageScroller
|
||||
}
|
||||
React.useImperativeHandle(ref, () => handle, [])
|
||||
return React.createElement('ScrollView', props)
|
||||
}),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
list,
|
||||
pressSend,
|
||||
render,
|
||||
renderedRow,
|
||||
root,
|
||||
scrollAwayFromTail,
|
||||
scrollToEnd,
|
||||
@@ -335,6 +336,21 @@ describe('MobileNativeChatView: tail-follow', () => {
|
||||
expect(control('Collapse tool runs')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('starts an initially empty switched-to native chat following, preserving Tools', async () => {
|
||||
const toolTurn = (id: string): ReturnType<typeof assistantTurn> => ({
|
||||
...assistantTurn(id, ''),
|
||||
blocks: [{ type: 'tool-call', name: 'Bash', callId: id, input: { command: 'ls' } }]
|
||||
})
|
||||
await render({ folded: [toolTurn('a')] })
|
||||
act(() => root().findAllByProps({ accessibilityLabel: 'Expand tool runs' })[0].props.onPress())
|
||||
await scrollAwayFromTail()
|
||||
await update({ sendSurfaceId: 'tab-b', folded: [] })
|
||||
await update({ sendSurfaceId: 'tab-b', folded: [toolTurn('b')] })
|
||||
act(() => list().props.onContentSizeChange(320, 1300))
|
||||
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 1300 })
|
||||
expect(renderedRow('b').props.toolsExpanded).toBe(true)
|
||||
})
|
||||
|
||||
it('repins after a keyboard-driven viewport layout only while following', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from './mobile-native-chat-disclosure-store'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Text,
|
||||
@@ -31,12 +30,9 @@ import {
|
||||
mobileNativeChatEmptyState,
|
||||
type MobileNativeChatPendingItem
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history'
|
||||
import { useMobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import {
|
||||
mobileNativeChatRowAnchor,
|
||||
useMobileNativeChatTailFollow
|
||||
} from './use-mobile-native-chat-tail-follow'
|
||||
import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
import {
|
||||
mobileNativeChatComposerPlaceholder,
|
||||
@@ -56,8 +52,10 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
|
||||
import { MobileNativeChatRowList } from './MobileNativeChatRowList'
|
||||
import { useMobileNativeChatRowRenderer } from './use-mobile-native-chat-row-renderer'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
import type { OlderPagePhase } from './use-mobile-older-page-loader'
|
||||
|
||||
/** Why the composer input is locked: the transport is disconnected, or the
|
||||
* terminal subscription has not acknowledged its input lease yet. */
|
||||
@@ -94,8 +92,9 @@ type Props = ComposerTrayProps & {
|
||||
* by the overlay against the transcript catching up. */
|
||||
streaming: string | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
olderPages?: OlderPagePhase
|
||||
/** `settled`: on the page, an older page waits for it so it never lands under a moving reader. */
|
||||
onLoadEarlier?: (settled?: () => Promise<void>) => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Route identity used to fence accepted sends that settle after a tab/view switch. */
|
||||
sendSurfaceId: string
|
||||
@@ -185,7 +184,7 @@ export function MobileNativeChatView({
|
||||
onStop,
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
olderPages = 'idle',
|
||||
onLoadEarlier,
|
||||
onSend,
|
||||
sendSurfaceId,
|
||||
@@ -250,19 +249,8 @@ export function MobileNativeChatView({
|
||||
}),
|
||||
[messages, folded, streaming, pending, imagePreviewsByMessageId]
|
||||
)
|
||||
const {
|
||||
listRef,
|
||||
showJumpToTail,
|
||||
pinToTail,
|
||||
pinToTailAfterContentResize,
|
||||
jumpToTail,
|
||||
beginUserScroll,
|
||||
endUserDrag,
|
||||
beginMomentum,
|
||||
endMomentum,
|
||||
readerIsScrolling,
|
||||
recordScrollMetrics
|
||||
} = useMobileNativeChatTailFollow({ items: data, scopeKey: sendSurfaceId })
|
||||
const tail = useMobileNativeChatTailFollow({ hasItems: data.length > 0, scopeKey: sendSurfaceId })
|
||||
const { jumpToTail, recordScrollMetrics, scrollMayLoadEarlier, pageSettled } = tail
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
@@ -280,18 +268,19 @@ export function MobileNativeChatView({
|
||||
[onSend, onClearSendError, jumpToTail]
|
||||
)
|
||||
|
||||
const loadEarlier = useCallback(() => onLoadEarlier?.(), [onLoadEarlier])
|
||||
const loadEarlier = useCallback(() => onLoadEarlier?.(pageSettled), [onLoadEarlier, pageSettled])
|
||||
|
||||
const onScroll = useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset } = e.nativeEvent
|
||||
recordScrollMetrics(e.nativeEvent)
|
||||
// Near the top — page in older history. Only the reader's scroll: a pin reports offsets too.
|
||||
if (contentOffset.y < 60 && hasMore && !loadingEarlier && readerIsScrolling()) {
|
||||
// Near the top — page in older history. Only the reader's drag: a pin reports offsets too.
|
||||
// After a failure only a tap retries, so a reader still at the top can't loop the spinner.
|
||||
if (contentOffset.y < 60 && hasMore && olderPages === 'idle' && scrollMayLoadEarlier()) {
|
||||
loadEarlier()
|
||||
}
|
||||
},
|
||||
[hasMore, loadingEarlier, loadEarlier, readerIsScrolling, recordScrollMetrics]
|
||||
[hasMore, olderPages, loadEarlier, scrollMayLoadEarlier, recordScrollMetrics]
|
||||
)
|
||||
|
||||
// Per-turn status rows: one live indicator while the turn runs, then a settled
|
||||
@@ -312,10 +301,13 @@ export function MobileNativeChatView({
|
||||
scopeKey: sendSurfaceId
|
||||
})
|
||||
|
||||
// What the reader opened in this chat's runs, kept above the rows the list recycles.
|
||||
// What the reader opened in this chat's runs, kept above the rows the list recycles or windows out.
|
||||
const rowKeys = useMobileNativeChatRowKeys(folded, messages, sendSurfaceId, turns.presentation)
|
||||
const disclosures = useMobileNativeChatDisclosures(sendSurfaceId)
|
||||
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(data), [data])
|
||||
const renderItem = useMobileNativeChatRowRenderer({
|
||||
rowKeyOf: rowKeys.keyOf,
|
||||
runOwners: rowKeys.runOwners,
|
||||
toolsExpanded,
|
||||
taskListPredecessors,
|
||||
fontScale,
|
||||
@@ -328,8 +320,6 @@ export function MobileNativeChatView({
|
||||
actionablePromptIds
|
||||
})
|
||||
|
||||
const history = useMobileNativeChatPageHistory(turns.listMessages, renderItem, sendSurfaceId)
|
||||
|
||||
const liveStatus = turns.liveLine ? (
|
||||
<MobileNativeChatLiveLine
|
||||
line={turns.liveLine}
|
||||
@@ -340,7 +330,15 @@ export function MobileNativeChatView({
|
||||
) : null
|
||||
const toolCallCount = useMemo(() => mobileNativeChatToolCallCount(folded), [folded])
|
||||
const toggleTools = useCallback(() => setToolsExpanded((v) => !v), [])
|
||||
const listContentStyle =
|
||||
toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent
|
||||
|
||||
const header = mobileNativeChatListHeader(hasMore, olderPages, loadEarlier)
|
||||
// How rows draw: a change re-lays every row, which the place-hold and the window both follow.
|
||||
const layoutKey = `${fontScale}:${toolsExpanded}`
|
||||
const pageHeader = (measuring: boolean) =>
|
||||
mobileNativeChatListHeader(hasMore, olderPages, loadEarlier, measuring)
|
||||
const footer = mobileNativeChatListFooter(liveStatus, turns.waitingRows, renderItem)
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
|
||||
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
|
||||
@@ -370,45 +368,30 @@ export function MobileNativeChatView({
|
||||
) : (
|
||||
<GestureHandlerRootView style={styles.listWrap}>
|
||||
<MobileNativeChatDisclosureContext.Provider value={disclosures}>
|
||||
<MobileNativeChatScrollHold headKey={turns.listMessages[0]?.id} listRef={listRef} />
|
||||
<GestureDetector gesture={pinchGesture}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={history.listRows}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={history.renderListRow}
|
||||
contentContainerStyle={
|
||||
toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent
|
||||
}
|
||||
// Let link/file taps land while the composer keyboard is up
|
||||
// instead of being swallowed by the dismiss gesture.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={onScroll}
|
||||
onScrollBeginDrag={beginUserScroll}
|
||||
onScrollEndDrag={endUserDrag}
|
||||
onMomentumScrollBegin={beginMomentum}
|
||||
onMomentumScrollEnd={endMomentum}
|
||||
scrollEventThrottle={32}
|
||||
onContentSizeChange={pinToTailAfterContentResize}
|
||||
onLayout={pinToTail}
|
||||
maintainVisibleContentPosition={mobileNativeChatRowAnchor(hasMore === true)}
|
||||
ListHeaderComponent={history.withHistory(
|
||||
mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier)
|
||||
)}
|
||||
ListFooterComponent={mobileNativeChatListFooter(
|
||||
liveStatus,
|
||||
turns.waitingRows,
|
||||
renderItem
|
||||
)}
|
||||
ListEmptyComponent={emptyStateView}
|
||||
/>
|
||||
</GestureDetector>
|
||||
<MobileNativeChatScrollHold
|
||||
{...{ rowKeys, layoutKey, ...tail.placeHold }}
|
||||
roots={disclosures.roots}
|
||||
runPlacements={rowKeys.runPlacements}
|
||||
>
|
||||
<GestureDetector gesture={pinchGesture}>
|
||||
<MobileNativeChatRowList
|
||||
{...{ tail, rowKeys, renderItem, footer, emptyStateView, onScroll, layoutKey }}
|
||||
rows={turns.listMessages}
|
||||
roots={disclosures.roots}
|
||||
rowState={turns.resolveRow}
|
||||
contentContainerStyle={listContentStyle}
|
||||
header={pageHeader}
|
||||
nativeHeader={header}
|
||||
hasHeader={hasMore === true}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</MobileNativeChatScrollHold>
|
||||
</MobileNativeChatDisclosureContext.Provider>
|
||||
<MobileNativeChatFloatingControls
|
||||
toolCallCount={toolCallCount}
|
||||
toolsExpanded={toolsExpanded}
|
||||
onToggleTools={toggleTools}
|
||||
showJumpToTail={showJumpToTail}
|
||||
showJumpToTail={tail.showJumpToTail}
|
||||
onJumpToTail={jumpToTail}
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
// disclosures live when the row itself may be unmounted: a run or line the reader opened
|
||||
// stays open after the list recycles its row, on reload, and across the Tools toggle.
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
MobileNativeChatScrollRoots,
|
||||
type MobileNativeChatRootPosition
|
||||
} from './mobile-native-chat-scroll-roots'
|
||||
|
||||
/** Where the reader left an opened run's own scroll box. */
|
||||
export type MobileNativeChatRunScroll = { top: number; followsEnd: boolean }
|
||||
export type MobileNativeChatRunScroll = MobileNativeChatRootPosition
|
||||
|
||||
export type MobileNativeChatDisclosureStore = {
|
||||
read: (key: string) => boolean | undefined
|
||||
@@ -13,6 +17,7 @@ export type MobileNativeChatDisclosureStore = {
|
||||
/** By the run's disclosure key, and forgotten when that run closes. A plain map, not state:
|
||||
* scrolling a run must not re-render the transcript. */
|
||||
runScroll: Map<string, MobileNativeChatRunScroll>
|
||||
roots: MobileNativeChatScrollRoots
|
||||
}
|
||||
|
||||
export const MobileNativeChatDisclosureContext =
|
||||
@@ -25,10 +30,17 @@ type ScopedDisclosures = {
|
||||
scopeKey: string
|
||||
open: ReadonlyMap<string, boolean>
|
||||
runScroll: Map<string, MobileNativeChatRunScroll>
|
||||
roots: MobileNativeChatScrollRoots
|
||||
}
|
||||
|
||||
function emptyDisclosures(scopeKey: string): ScopedDisclosures {
|
||||
return { scopeKey, open: new Map(), runScroll: new Map() }
|
||||
const runScroll = new Map<string, MobileNativeChatRunScroll>()
|
||||
return {
|
||||
scopeKey,
|
||||
open: new Map(),
|
||||
runScroll,
|
||||
roots: new MobileNativeChatScrollRoots(runScroll, MAX_DISCLOSURES)
|
||||
}
|
||||
}
|
||||
|
||||
/** One store per chat: another chat's choices never leak in. */
|
||||
@@ -37,12 +49,13 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh
|
||||
if (state.scopeKey !== scopeKey) {
|
||||
setState(emptyDisclosures(scopeKey))
|
||||
}
|
||||
const { open, runScroll } = state
|
||||
const { open, runScroll, roots } = state
|
||||
useLayoutEffect(() => () => roots.dispose(), [roots])
|
||||
const write = useCallback(
|
||||
(key: string, next: boolean) => {
|
||||
// Forgotten with the run that closed, as desktop does.
|
||||
if (!next) {
|
||||
runScroll.delete(key)
|
||||
roots.forget(key)
|
||||
}
|
||||
setState((current) => {
|
||||
if (current.open.get(key) === next) {
|
||||
@@ -60,11 +73,11 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh
|
||||
return { ...current, open: updated }
|
||||
})
|
||||
},
|
||||
[runScroll]
|
||||
[roots]
|
||||
)
|
||||
return useMemo(
|
||||
() => ({ read: (key: string) => open.get(key), write, runScroll }),
|
||||
[open, write, runScroll]
|
||||
() => ({ read: (key: string) => open.get(key), write, runScroll, roots }),
|
||||
[open, write, runScroll, roots]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** The fold rebuilt this row with nothing in it changed: the same fields and the same block objects
|
||||
* in the same order. Its id only names the candidate; content decides. */
|
||||
function unchanged(previous: NativeChatMessage, next: NativeChatMessage): boolean {
|
||||
if (
|
||||
previous.blocks.length !== next.blocks.length ||
|
||||
previous.blocks.some((block, at) => block !== next.blocks[at])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const fields = new Map(Object.entries(previous))
|
||||
const nextFields = Object.entries(next)
|
||||
return (
|
||||
fields.size === nextFields.length &&
|
||||
nextFields.every(([name, value]) => name === 'blocks' || fields.get(name) === value)
|
||||
)
|
||||
}
|
||||
|
||||
/** The folded rows, keeping each unchanged row's last committed object: a raw message re-folds the
|
||||
* whole window, which copies every tool run, so rows that drew nothing new would draw again. Only
|
||||
* the last committed rows of the chat shown are kept. */
|
||||
export function useMobileNativeChatFoldReuse(
|
||||
folded: NativeChatMessage[],
|
||||
scope: string
|
||||
): NativeChatMessage[] {
|
||||
const committed = useRef<{ scope: string; byId: Map<string, NativeChatMessage> } | null>(null)
|
||||
const rows = useMemo(() => {
|
||||
const byId = committed.current?.scope === scope ? committed.current.byId : null
|
||||
let kept = 0
|
||||
const out = folded.map((row) => {
|
||||
const previous = byId?.get(row.id)
|
||||
if (previous && previous !== row && unchanged(previous, row)) {
|
||||
kept += 1
|
||||
return previous
|
||||
}
|
||||
return row
|
||||
})
|
||||
return kept > 0 ? out : folded
|
||||
}, [folded, scope])
|
||||
useLayoutEffect(() => {
|
||||
committed.current = { scope, byId: new Map(rows.map((row) => [row.id, row])) }
|
||||
}, [rows, scope])
|
||||
return rows
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text } from 'react-native'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import type { OlderPagePhase } from './use-mobile-older-page-loader'
|
||||
|
||||
/** The "Load earlier messages" control, while older history remains. */
|
||||
const LOAD_EARLIER_FAILED = "Couldn't load earlier messages. Tap to try again."
|
||||
const EARLIER_UNAVAILABLE = "Earlier messages can't be loaded."
|
||||
|
||||
/** The "Load earlier messages" control, while older history remains. `measuring`: the page has loaded
|
||||
* rows above it still to draw, so it shows loading until they are in. */
|
||||
export function mobileNativeChatListHeader(
|
||||
hasMore: boolean | undefined,
|
||||
loadingEarlier: boolean | undefined,
|
||||
loadEarlier: () => void
|
||||
olderPages: OlderPagePhase | undefined,
|
||||
loadEarlier: () => void,
|
||||
measuring = false
|
||||
): ReactElement | null {
|
||||
if (!hasMore) {
|
||||
if (olderPages === 'unavailable' && !measuring) {
|
||||
// No retry gets past it, so the control gives way to a line that says so.
|
||||
return (
|
||||
<View style={styles.loadEarlier}>
|
||||
<Text style={styles.loadEarlierFailedText}>{EARLIER_UNAVAILABLE}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (!hasMore && !measuring) {
|
||||
return null
|
||||
}
|
||||
const loadingEarlier = measuring || olderPages === 'loading'
|
||||
return (
|
||||
<Pressable style={styles.loadEarlier} onPress={loadEarlier} disabled={loadingEarlier}>
|
||||
{loadingEarlier ? (
|
||||
@@ -19,6 +34,9 @@ export function mobileNativeChatListHeader(
|
||||
) : (
|
||||
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
|
||||
)}
|
||||
{olderPages === 'failed' ? (
|
||||
<Text style={styles.loadEarlierFailedText}>{LOAD_EARLIER_FAILED}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { scrollWritesStopMomentum } from './mobile-native-chat-page-motion'
|
||||
|
||||
type Styled = {
|
||||
style?: { overflowAnchor: string }
|
||||
scrollTop: number
|
||||
getBoundingClientRect: () => unknown
|
||||
}
|
||||
|
||||
/** Who keeps a parked reader's place where the browser has scroll anchoring (not WebKit): the
|
||||
* browser, which sees every change inside the rows on screen, except while the page owns the
|
||||
* scroll: following (its end pin) or a place-hold's own correction. Two owners would each put the
|
||||
* same move back. */
|
||||
export class MobileNativeChatPageAnchoring {
|
||||
private holds = 0
|
||||
private readonly none = scrollWritesStopMomentum()
|
||||
|
||||
constructor(
|
||||
private readonly scroller: () => Styled | null | undefined,
|
||||
private readonly following: () => boolean
|
||||
) {}
|
||||
|
||||
/** The page takes the scroll for one correction, or gives it back. */
|
||||
hold(on: boolean): void {
|
||||
this.holds = Math.max(0, this.holds + (on ? 1 : -1))
|
||||
this.sync()
|
||||
}
|
||||
|
||||
sync(): void {
|
||||
const s = this.scroller()
|
||||
const next = this.holds === 0 && !this.following() ? 'auto' : 'none'
|
||||
if (this.none || !s?.style || s.style.overflowAnchor === next) {
|
||||
return
|
||||
}
|
||||
// Laid out under the old owner first, and the new one in force before anything changes.
|
||||
s.getBoundingClientRect()
|
||||
s.style.overflowAnchor = next
|
||||
s.getBoundingClientRect()
|
||||
if (next === 'auto') {
|
||||
// Not one kept from before the page's turn, whose move the browser would then put back too.
|
||||
this.rechoose(s)
|
||||
}
|
||||
}
|
||||
|
||||
/** After each commit. While the browser holds the place, rows new on screen can't be its anchor
|
||||
* until laid out, so it kept the container around them, whose top never moves. Laid out now, a
|
||||
* scroll makes it choose among them. */
|
||||
committed(): void {
|
||||
this.sync()
|
||||
const s = this.scroller()
|
||||
if (this.none || this.holds > 0 || s?.style?.overflowAnchor !== 'auto') {
|
||||
return
|
||||
}
|
||||
s.getBoundingClientRect()
|
||||
this.rechoose(s)
|
||||
}
|
||||
|
||||
/** A scroll makes the browser choose its anchor afresh; at the very top it keeps none anyway. It
|
||||
* ends where it started, so the scroll event after it reports only what else moved the scroll;
|
||||
* taken as the page's own writes, a reader's scroll landing there would be too. */
|
||||
private rechoose(s: Styled): void {
|
||||
const top = s.scrollTop
|
||||
if (top >= 0.5) {
|
||||
s.scrollTop = top - 1
|
||||
s.scrollTop = top
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
it('retires replaced source payloads without changing spacers or sweeping same-key content updates', () => {
|
||||
const cache = new MobileNativeChatPageHeights()
|
||||
const row = (id: string): NativeChatMessage => ({
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [],
|
||||
source: 'transcript',
|
||||
timestamp: 1
|
||||
})
|
||||
const keyOf = (m: NativeChatMessage) => m.id
|
||||
const state: MobileNativeChatTurnRow = {
|
||||
turnStatus: null,
|
||||
turnExpanded: false,
|
||||
activeTurnIsWorking: false,
|
||||
trailingRun: false,
|
||||
mayStillGrow: false,
|
||||
reasoningExpanded: false,
|
||||
onToggleReasoning: () => {},
|
||||
onToggleSubagentGroup: () => {},
|
||||
reasoningIsLive: false,
|
||||
userMessageOpen: false,
|
||||
onToggleUserMessage: () => {}
|
||||
}
|
||||
cache.remember('head', 80, row('head'), state, 'layout')
|
||||
cache.remember('retired', 20, row('retired'), state, 'layout')
|
||||
const before = ['head', 'retired', 'unknown']
|
||||
const source = cache.sourceKeys(['head', 'replacement', 'unknown'].map(row), keyOf, before)
|
||||
expect(source.changed).toBe(true)
|
||||
const slack = cache.px('retired')!
|
||||
const geometry = cache.spacers(source.keys, [0, 0], [0, 1], slack)
|
||||
cache.retain(source.keys)
|
||||
expect(cache.px('retired')).toBeUndefined()
|
||||
expect(cache.spacers(source.keys, [0, 0], [0, 1], slack)).toEqual(geometry)
|
||||
expect(cache.sourceKeys(source.keys.map(row), keyOf, source.keys).changed).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
const FIRST_ESTIMATE = 80
|
||||
|
||||
/** The drawn rows, as the measuring choice reads them. */
|
||||
type DrawnRows = {
|
||||
keys: readonly string[]
|
||||
rows: readonly NativeChatMessage[]
|
||||
rowState: (index: number, row: NativeChatMessage) => MobileNativeChatTurnRow
|
||||
start: number
|
||||
/** The first row in the document: rows above it were never measured. */
|
||||
first: number
|
||||
following: boolean
|
||||
}
|
||||
|
||||
type Entry = { px: number; row: NativeChatMessage; state: MobileNativeChatTurnRow; layout: string }
|
||||
|
||||
function sameState(a: MobileNativeChatTurnRow, b: MobileNativeChatTurnRow): boolean {
|
||||
const before = Object.entries(a)
|
||||
const after = new Map(Object.entries(b))
|
||||
return before.length === after.size && before.every(([name, value]) => after.get(name) === value)
|
||||
}
|
||||
|
||||
/** Row heights as drawn. One holds while the row would draw the same: the same row object (unchanged
|
||||
* rows keep theirs across a re-fold), the same row state and the same layout. A stale one keeps
|
||||
* standing in for its row until it is measured again, so going stale never moves anything. */
|
||||
export class MobileNativeChatPageHeights {
|
||||
private entries = new Map<string, Entry>()
|
||||
private sum = 0
|
||||
private samples = 0
|
||||
|
||||
px(key: string | undefined): number | undefined {
|
||||
return key === undefined ? undefined : this.entries.get(key)?.px
|
||||
}
|
||||
|
||||
/** For rows never drawn, which only ever sit below the reader. */
|
||||
estimate(): number {
|
||||
return this.samples > 0 ? this.sum / this.samples : FIRST_ESTIMATE
|
||||
}
|
||||
|
||||
valid(
|
||||
key: string,
|
||||
row: NativeChatMessage,
|
||||
state: MobileNativeChatTurnRow,
|
||||
layout: string
|
||||
): boolean {
|
||||
const entry = this.entries.get(key)
|
||||
return (
|
||||
entry !== undefined &&
|
||||
entry.row === row &&
|
||||
entry.layout === layout &&
|
||||
sameState(entry.state, state)
|
||||
)
|
||||
}
|
||||
|
||||
remember(
|
||||
key: string,
|
||||
px: number,
|
||||
row: NativeChatMessage,
|
||||
state: MobileNativeChatTurnRow,
|
||||
layout: string
|
||||
): void {
|
||||
if (!this.entries.has(key)) {
|
||||
this.samples += 1
|
||||
}
|
||||
this.sum += px - (this.entries.get(key)?.px ?? 0)
|
||||
this.entries.set(key, { px, row, state, layout })
|
||||
}
|
||||
|
||||
sourceKeys(
|
||||
rows: readonly NativeChatMessage[],
|
||||
keyOf: (row: NativeChatMessage) => string,
|
||||
before: readonly string[] | undefined
|
||||
): {
|
||||
keys: string[]
|
||||
changed: boolean
|
||||
indexOf: (key: string | undefined) => number | undefined
|
||||
} {
|
||||
let changed = before?.length !== rows.length
|
||||
const keys = rows.map((row, index) => {
|
||||
const key = keyOf(row)
|
||||
changed ||= before?.[index] !== key
|
||||
return key
|
||||
})
|
||||
let index: Map<string, number> | undefined
|
||||
const indexOf = (key: string | undefined) =>
|
||||
key === undefined ? undefined : (index ??= new Map(keys.map((k, at) => [k, at]))).get(key)
|
||||
return { keys, changed, indexOf }
|
||||
}
|
||||
|
||||
/** Retired payloads die after their heights supplied trim slack; estimation stays numeric. */
|
||||
retain(keys: readonly string[]): void {
|
||||
const current = new Set(keys)
|
||||
for (const key of this.entries.keys()) {
|
||||
if (!current.has(key)) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spacer heights around the measuring rows `[m0, m1)` and the window `[start, end)`: above them
|
||||
* only the measured run (from `first`, plus trim `slack`), between them, and below the window. */
|
||||
spacers(
|
||||
keys: readonly string[],
|
||||
[m0, m1]: [number, number],
|
||||
[start, end]: [number, number],
|
||||
slack: number
|
||||
): { first: number; above: number; mid: number; bottom: number } {
|
||||
let [first, above, mid, bottom] = [m0, slack, 0, 0]
|
||||
for (let px = this.px(keys[first - 1]); first > 0 && px !== undefined;) {
|
||||
above += px
|
||||
first -= 1
|
||||
px = this.px(keys[first - 1])
|
||||
}
|
||||
for (let i = m1; i < start; i += 1) {
|
||||
mid += this.px(keys[i]) ?? 0
|
||||
}
|
||||
for (let i = end; i < keys.length; i += 1) {
|
||||
bottom += this.px(keys[i]) ?? this.estimate()
|
||||
}
|
||||
return { first, above, mid, bottom }
|
||||
}
|
||||
|
||||
/** The run of rows nearest above `start` whose heights don't hold, `[from, to]`, at most `rows`
|
||||
* long and within `reach` px of measured rows above the window; null when there is none. */
|
||||
nearestStale(
|
||||
drawn: Pick<DrawnRows, 'keys' | 'rows' | 'rowState' | 'start'>,
|
||||
layout: string,
|
||||
reach: number,
|
||||
rows: number
|
||||
): [number, number] | null {
|
||||
const { keys, start } = drawn
|
||||
const valid = (i: number): boolean =>
|
||||
this.valid(keys[i]!, drawn.rows[i]!, drawn.rowState(i, drawn.rows[i]!), layout)
|
||||
let [to, distance] = [start - 1, 0]
|
||||
for (; to >= 0 && distance <= reach && valid(to); to -= 1) {
|
||||
distance += this.px(keys[to]) ?? 0
|
||||
}
|
||||
if (to < 0 || distance > reach) {
|
||||
return null
|
||||
}
|
||||
let from = to
|
||||
while (from > 0 && to - from + 1 < rows && !valid(from - 1)) {
|
||||
from -= 1
|
||||
}
|
||||
return [from, to]
|
||||
}
|
||||
|
||||
/** The rows to measure next: stale ones within `near` px above the window; following, then the
|
||||
* rows never drawn just above the measured run; then, with `full`, stale ones anywhere above. */
|
||||
next(
|
||||
drawn: DrawnRows,
|
||||
layout: string,
|
||||
near: number,
|
||||
rows: number,
|
||||
full: boolean
|
||||
): [number, number] | null {
|
||||
const close = this.nearestStale(drawn, layout, near, rows)
|
||||
if (close || !drawn.following) {
|
||||
return close
|
||||
}
|
||||
if (drawn.first > 0) {
|
||||
return [Math.max(0, drawn.first - rows), drawn.first - 1]
|
||||
}
|
||||
return full ? this.nearestStale(drawn, layout, Infinity, rows) : null
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear()
|
||||
this.sum = 0
|
||||
this.samples = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/** No touch and no scroll of the reader's for this long: a write any sooner can stop iOS momentum. */
|
||||
const REST_MS = 150
|
||||
/** Input this recent makes a move the reader's where the browser also moves the scroll. */
|
||||
const INTENT_MS = 1_000
|
||||
const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '])
|
||||
type Moved = { by: 'own' | 'layout' | 'reader' | 'none'; moved: number }
|
||||
export type MobileNativeChatPageObservation = Moved & { fromEnd: number }
|
||||
|
||||
/** WebKit (every iOS browser, and Safari) stops a fling on any scroll write and has no scroll
|
||||
* anchoring; other engines keep momentum, and their anchoring keeps a parked reader's place. */
|
||||
export function scrollWritesStopMomentum(): boolean {
|
||||
const agent = globalThis.navigator?.userAgent ?? ''
|
||||
return /AppleWebKit/.test(agent) && !/Chrome|Chromium|Android/.test(agent)
|
||||
}
|
||||
|
||||
/** Whether the page keeps a parked place itself: on WebKit, which has no scroll anchoring, and at the
|
||||
* very top, where Chromium's anchoring never adjusts (rows measured above would push the reader). */
|
||||
export function pageKeepsPlace(scrollTop: number): boolean {
|
||||
return scrollWritesStopMomentum() || scrollTop < 0.5
|
||||
}
|
||||
|
||||
type Listener = (event: unknown) => void
|
||||
type Listened = {
|
||||
addEventListener: (type: string, listener: Listener, options: { passive: true }) => void
|
||||
removeEventListener: (type: string, listener: Listener) => void
|
||||
}
|
||||
|
||||
/** Which way a scroll key the page scrolls by moves it (1 down, -1 up); 0 for any other key, or one
|
||||
* typed into the composer. */
|
||||
function keyWay(event: unknown): number {
|
||||
if (typeof KeyboardEvent === 'undefined' || !(event instanceof KeyboardEvent)) {
|
||||
return 0
|
||||
}
|
||||
const { key, shiftKey, target } = event
|
||||
const typing =
|
||||
typeof HTMLElement !== 'undefined' &&
|
||||
target instanceof HTMLElement &&
|
||||
(/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)
|
||||
if (typing || !SCROLL_KEYS.has(key)) {
|
||||
return 0
|
||||
}
|
||||
return /Up|Home/.test(key) || (key === ' ' && shiftKey) ? -1 : 1
|
||||
}
|
||||
|
||||
function wheelWay(event: unknown): number {
|
||||
const deltaY = typeof event === 'object' && event !== null && 'deltaY' in event && event.deltaY
|
||||
return typeof deltaY === 'number' ? Math.sign(deltaY) : 0
|
||||
}
|
||||
|
||||
function touchY(event: unknown): number | undefined {
|
||||
const touches = typeof event === 'object' && event !== null && 'touches' in event && event.touches
|
||||
const first = touches && typeof touches === 'object' && 0 in touches ? touches[0] : undefined
|
||||
const y = typeof first === 'object' && first !== null && 'clientY' in first && first.clientY
|
||||
return typeof y === 'number' ? y : undefined
|
||||
}
|
||||
|
||||
/** Whether the reader is moving the page, told apart from the page's own writes. Work that can move
|
||||
* rows above the reader (a landing, a measured row, a correction) waits until it settles. */
|
||||
export class MobileNativeChatPageMotion {
|
||||
private touching = false
|
||||
private pointing = false
|
||||
private lastInput = 0
|
||||
/** Which way the reader's last input moves the page: 1 down, -1 up, 0 unknown (the scrollbar). */
|
||||
private way = 0
|
||||
private lastTouchY: number | undefined
|
||||
private lastReaderScroll = 0
|
||||
/** The browser moves the scroll too (its anchoring), so only input makes a move the reader's. */
|
||||
private readonly browserMoves = !scrollWritesStopMomentum()
|
||||
/** Where the page last accounted for the scroll: its write, an observed scroll, a resize. */
|
||||
private seen = 0
|
||||
private seenMax = Infinity
|
||||
/** A commit changed what is drawn and its resize callback hasn't run: a move now is layout's. */
|
||||
private unreconciled = false
|
||||
private drawn = ''
|
||||
private layouts = 0
|
||||
/** Where the page's own writes put the scroller, until their scroll event arrives. */
|
||||
private own: number[] = []
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private later: ReturnType<typeof setTimeout> | undefined
|
||||
/** Owed work doesn't run again before this, so other work gets frames between its rounds. */
|
||||
private quietUntil = 0
|
||||
/** Classified before the follower sees geometry, including layout and the page's writes. */
|
||||
onMotion: ((observation: MobileNativeChatPageObservation) => void) | null = null
|
||||
private waiters: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private readonly onSettled: () => void,
|
||||
initialTop = 0
|
||||
) {
|
||||
this.seen = initialTop
|
||||
}
|
||||
|
||||
/** Follows the reader's hand on the scroller (touch, wheel, scroll keys, the scrollbar); returns
|
||||
* the detach. */
|
||||
attach(scroller: Listened, acceptsKey: (event: unknown) => boolean = () => true): () => void {
|
||||
const input = (way: number) => {
|
||||
;[this.lastInput, this.way] = [Date.now(), way]
|
||||
}
|
||||
const key = (event: unknown) => {
|
||||
const way = keyWay(event)
|
||||
if (way !== 0 && acceptsKey(event)) {
|
||||
input(way)
|
||||
}
|
||||
}
|
||||
const move = (event: unknown) => {
|
||||
const [from, y] = [this.lastTouchY, touchY(event)]
|
||||
this.lastTouchY = y
|
||||
// A finger moving up scrolls the page down.
|
||||
input(from !== undefined && y !== undefined && y !== from ? Math.sign(from - y) : this.way)
|
||||
}
|
||||
const start = (event: unknown) => {
|
||||
this.lastTouchY = touchY(event)
|
||||
input(0)
|
||||
this.touch(true)
|
||||
}
|
||||
// The scrollbar, which is the scroller's own; a press on a row inside is no scroll.
|
||||
const point = (event: unknown) => {
|
||||
if (
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
'target' in event &&
|
||||
event.target === scroller
|
||||
) {
|
||||
this.pointing = true
|
||||
this.way = 0
|
||||
}
|
||||
}
|
||||
// Released, or moving with no button down: a pointerup lost after a scrollbar drag can't stick.
|
||||
const up = (event: unknown) => {
|
||||
if (!(typeof event === 'object' && event !== null && 'buttons' in event && event.buttons)) {
|
||||
this.pointing = false
|
||||
}
|
||||
}
|
||||
const listeners: [Listened, string, Listener][] = [
|
||||
[scroller, 'touchstart', start],
|
||||
[scroller, 'touchend', () => this.touch(false)],
|
||||
[scroller, 'touchcancel', () => this.touch(false)],
|
||||
[scroller, 'touchmove', move],
|
||||
[scroller, 'wheel', (event) => input(wheelWay(event))],
|
||||
[scroller, 'pointerdown', point]
|
||||
]
|
||||
// A scrollbar drag can end, and a scroll key arrive, outside the scroller.
|
||||
if ('addEventListener' in globalThis) {
|
||||
listeners.push([globalThis, 'pointerup', up], [globalThis, 'pointercancel', up])
|
||||
listeners.push([globalThis, 'pointermove', up], [globalThis, 'keydown', key])
|
||||
}
|
||||
listeners.forEach(([target, type, fn]) => target.addEventListener(type, fn, { passive: true }))
|
||||
return () => listeners.forEach(([target, type, fn]) => target.removeEventListener(type, fn))
|
||||
}
|
||||
|
||||
touch(down: boolean): void {
|
||||
this.touching = down
|
||||
this.lastInput = Date.now()
|
||||
if (down) {
|
||||
this.own = []
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
} else {
|
||||
this.poke()
|
||||
}
|
||||
}
|
||||
|
||||
/** A commit drew `drawn`. When that changed, a move is layout's until its resize callback (or the
|
||||
* next frame, when nothing resized); scroll events run before that frame's resize callbacks. */
|
||||
laidOut(drawn: string): void {
|
||||
if (drawn === this.drawn) {
|
||||
return
|
||||
}
|
||||
this.drawn = drawn
|
||||
this.unreconciled = true
|
||||
const token = ++this.layouts
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
if (token === this.layouts) {
|
||||
this.unreconciled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The resize callback reconciled layout: what moved the scroll since the page last accounted
|
||||
* for it is now told apart (a scroll event may not have reported it yet). */
|
||||
reconciled(scrollTop: number, max: number): Moved {
|
||||
this.unreconciled = false
|
||||
return this.account(scrollTop, max)
|
||||
}
|
||||
|
||||
/** What moved the scroll to `scrollTop` since the page last accounted for it, and by how much. The
|
||||
* page's write; layout while a commit is unreconciled or rows above the reading place moved
|
||||
* (`placeMoved`), left for the resize callback to tell apart; else as `account`. */
|
||||
scrolled(scrollTop: number, max: number, placeMoved: () => boolean): Moved {
|
||||
const moved = scrollTop - this.seen
|
||||
const at = this.own.findIndex((target) => Math.abs(target - scrollTop) <= 1)
|
||||
if (at !== -1) {
|
||||
this.own.splice(0, at + 1)
|
||||
;[this.seen, this.seenMax] = [scrollTop, max]
|
||||
return this.observed('own', moved, max - scrollTop)
|
||||
}
|
||||
if (Math.abs(moved) >= 0.5 && !this.touching && (this.unreconciled || placeMoved())) {
|
||||
return this.observed('layout', moved, max - scrollTop)
|
||||
}
|
||||
return this.account(scrollTop, max)
|
||||
}
|
||||
|
||||
/** A clamp onto a shorter end is layout's; any other move is the reader's (a finger makes it so),
|
||||
* except where the browser moves the scroll too: there only input (or the fling it started) does. */
|
||||
private account(scrollTop: number, max: number): Moved {
|
||||
const [from, fromMax, moved] = [this.seen, this.seenMax, scrollTop - this.seen]
|
||||
;[this.seen, this.seenMax] = [scrollTop, max]
|
||||
if (Math.abs(moved) < 0.5) {
|
||||
return this.observed('none', moved, max - scrollTop)
|
||||
}
|
||||
// Pulled back onto a shorter end from where the old one allowed; iOS bouncing past an end is not.
|
||||
const clamped = Math.abs(scrollTop - max) < 0.5 && from > max + 0.5 && from <= fromMax + 0.5
|
||||
if ((clamped && !this.touching) || (this.browserMoves && !this.intended(moved))) {
|
||||
return this.observed('layout', moved, max - scrollTop)
|
||||
}
|
||||
this.lastReaderScroll = Date.now()
|
||||
this.poke()
|
||||
return this.observed('reader', moved, max - scrollTop)
|
||||
}
|
||||
|
||||
private observed(by: Moved['by'], moved: number, fromEnd: number): Moved {
|
||||
this.onMotion?.({ by, moved, fromEnd })
|
||||
return { by, moved }
|
||||
}
|
||||
|
||||
/** Input is recent (a fling counts from the finger's release) and the move goes its way: the
|
||||
* browser's anchoring moving against it is the browser's, so it can't detach the reader. */
|
||||
private intended(moved: number): boolean {
|
||||
const recent = this.touching || this.pointing || Date.now() - this.lastInput <= INTENT_MS
|
||||
return recent && (this.way === 0 || Math.sign(moved) === this.way)
|
||||
}
|
||||
|
||||
wrote(target: number): void {
|
||||
this.own = [...this.own.slice(-7), target]
|
||||
this.seen = target
|
||||
}
|
||||
|
||||
settled(): boolean {
|
||||
return !this.touching && Date.now() - this.lastReaderScroll >= REST_MS
|
||||
}
|
||||
|
||||
/** A place correction may be written now: at once where a write keeps momentum (as the common
|
||||
* list does), else only at rest. */
|
||||
readonly mayCorrect = scrollWritesStopMomentum() ? () => this.settled() : () => true
|
||||
|
||||
/** Resolves once settled, or when the window goes away (`release`), so a wait never strands. */
|
||||
whenSettled(): Promise<void> {
|
||||
if (this.settled()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve)
|
||||
this.poke()
|
||||
})
|
||||
}
|
||||
|
||||
poke(): void {
|
||||
if (this.touching || this.timer !== undefined) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const wait = Math.max(0, REST_MS - (now - this.lastReaderScroll), this.quietUntil - now)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined
|
||||
if (!this.settled() || Date.now() < this.quietUntil) {
|
||||
this.poke()
|
||||
return
|
||||
}
|
||||
this.waiters.splice(0).forEach((resolve) => resolve())
|
||||
this.onSettled()
|
||||
}, wait)
|
||||
}
|
||||
|
||||
/** Owed work waits `ms` before its next round. */
|
||||
quiet(ms: number): void {
|
||||
this.quietUntil = Date.now() + ms
|
||||
}
|
||||
|
||||
/** Checks for owed work again after `ms`, when nothing else will. */
|
||||
pokeIn(ms: number): void {
|
||||
clearTimeout(this.later)
|
||||
this.later = setTimeout(() => this.poke(), ms)
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.touching = false
|
||||
this.pointing = false
|
||||
this.lastReaderScroll = 0
|
||||
this.own = []
|
||||
clearTimeout(this.later)
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.waiters.splice(0).forEach((resolve) => resolve())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
type Scroller = { getBoundingClientRect: () => { top: number }; scrollTop: number }
|
||||
import { mobileNativeChatScrollFrame } from './mobile-native-chat-scroll-roots'
|
||||
|
||||
function isElement(node: unknown): node is Element {
|
||||
return typeof Element !== 'undefined' && node instanceof Element
|
||||
}
|
||||
|
||||
/** The part of `row` at the screen's top line `view`: the first part starting on screen, or the
|
||||
* innermost one crossing the line, as the browser's own scroll anchoring would pick. Growth above
|
||||
* it inside the row then moves it, which the row's own top doesn't show. */
|
||||
function partAtTop(row: Element, view: number): Element {
|
||||
let part = row
|
||||
for (let child = row.firstElementChild; child;) {
|
||||
const box = child.getBoundingClientRect()
|
||||
if (box.bottom <= view || box.height === 0) {
|
||||
child = child.nextElementSibling
|
||||
} else if (mobileNativeChatScrollFrame(child, row) || box.top >= view) {
|
||||
return child
|
||||
} else {
|
||||
part = child
|
||||
child = child.firstElementChild
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
/** The page's drawn row nodes by key, each in the one observer while drawn, and the row the reader
|
||||
* is reading: the first drawn row on screen and its offset from the scroller's top. */
|
||||
export class MobileNativeChatPageRows {
|
||||
private nodes = new Map<string, Element>()
|
||||
private keys = new WeakMap<Element, string>()
|
||||
private refs = new Map<string, (node: unknown) => void>()
|
||||
private observer: ResizeObserver | null = null
|
||||
/** The row and the part of it at the screen's top; `top` from the scroller's top, with the
|
||||
* `scrollTop` it was read at. */
|
||||
private anchor: { key: string; part: Element; top: number; scrollTop: number } | null = null
|
||||
|
||||
keyOf(node: Element): string | undefined {
|
||||
return this.keys.get(node)
|
||||
}
|
||||
|
||||
node(key: string | undefined): Element | undefined {
|
||||
return key === undefined ? undefined : this.nodes.get(key)
|
||||
}
|
||||
|
||||
readonly observeWith = (observer: ResizeObserver | null): void => {
|
||||
this.observer = observer
|
||||
this.nodes.forEach((node) => observer?.observe(node))
|
||||
}
|
||||
|
||||
/** One ref per key for as long as the row is drawn, so a streamed commit never re-observes it. */
|
||||
readonly ref = (key: string): ((node: unknown) => void) => {
|
||||
let ref = this.refs.get(key)
|
||||
if (!ref) {
|
||||
ref = (node: unknown) => {
|
||||
const was = this.nodes.get(key)
|
||||
if (was) {
|
||||
this.observer?.unobserve(was)
|
||||
this.nodes.delete(key)
|
||||
}
|
||||
if (isElement(node)) {
|
||||
this.nodes.set(key, node)
|
||||
this.keys.set(node, key)
|
||||
this.observer?.observe(node)
|
||||
} else {
|
||||
this.refs.delete(key)
|
||||
}
|
||||
}
|
||||
this.refs.set(key, ref)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
/** `deep`: anchor the part of the row at the screen's top, not the row's own top. */
|
||||
takeAnchor(
|
||||
scroller: Scroller,
|
||||
keys: readonly string[],
|
||||
[start, end]: [number, number],
|
||||
deep: boolean
|
||||
): void {
|
||||
const view = scroller.getBoundingClientRect().top
|
||||
let [low, high] = [start, end]
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2)
|
||||
const node = this.node(keys[middle])
|
||||
if (!node || node.getBoundingClientRect().bottom <= view) {
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle
|
||||
}
|
||||
}
|
||||
const key = keys[low]
|
||||
const node = this.node(key)
|
||||
const part = node && (deep ? partAtTop(node, view) : node)
|
||||
const top = part && part.getBoundingClientRect().top - view
|
||||
this.anchor =
|
||||
key && part && top !== undefined ? { key, part, top, scrollTop: scroller.scrollTop } : null
|
||||
}
|
||||
|
||||
/** How far the anchored part moved in the content since it was taken (what is above it changed
|
||||
* size), apart from any scrolling; null when it isn't drawn. */
|
||||
placeShift(scroller: Scroller): number | null {
|
||||
const part = this.anchor?.part
|
||||
if (!this.anchor || !part?.isConnected || !this.node(this.anchor.key)) {
|
||||
return null
|
||||
}
|
||||
const now = part.getBoundingClientRect().top - scroller.getBoundingClientRect().top
|
||||
return now + scroller.scrollTop - this.anchor.top - this.anchor.scrollTop
|
||||
}
|
||||
|
||||
placeMoved(scroller: Scroller): boolean {
|
||||
return Math.abs(this.placeShift(scroller) ?? 0) >= 0.5
|
||||
}
|
||||
|
||||
forgetAnchor(): void {
|
||||
this.anchor = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** The drawn rows `[start, end)` of `n`, the first drawn row's top in content coordinates, and the
|
||||
* scroller's viewport. */
|
||||
export type MobileNativeChatWindowGeometry = {
|
||||
n: number
|
||||
start: number
|
||||
end: number
|
||||
following: boolean
|
||||
windowTop: number
|
||||
scrollTop: number
|
||||
viewport: number
|
||||
}
|
||||
|
||||
/** Rows within one screen of the viewport are drawn, rows past two are not; null when nothing
|
||||
* changes. Rows above the window come only from the spacer, which holds measured heights, so drawing
|
||||
* or dropping them never moves what the reader sees. `known` is a row's measured height. */
|
||||
export function coverMobileNativeChatWindow(
|
||||
g: MobileNativeChatWindowGeometry,
|
||||
known: (index: number) => number | undefined,
|
||||
estimate: number
|
||||
): { start: number; end: number } | null {
|
||||
const { n, following, scrollTop, viewport } = g
|
||||
const height = (at: number): number => known(at) ?? estimate
|
||||
const [near, far] = [scrollTop - viewport, scrollTop + 2 * viewport]
|
||||
let [start, end, top] = [g.start, g.end, g.windowTop]
|
||||
let bottom = top
|
||||
for (let at = start; at < end; at += 1) {
|
||||
bottom += height(at)
|
||||
}
|
||||
if (!following && (top > scrollTop + viewport || bottom < scrollTop)) {
|
||||
// Nothing drawn is on screen, so nothing needs to stay still: draw where the reader is.
|
||||
if (top > scrollTop) {
|
||||
while (start > 0 && known(start - 1) !== undefined && top > scrollTop) {
|
||||
start -= 1
|
||||
top -= known(start)!
|
||||
}
|
||||
} else {
|
||||
for (top = bottom, start = end; start < n - 1 && top + height(start) <= scrollTop;) {
|
||||
top += height(start)
|
||||
start += 1
|
||||
}
|
||||
}
|
||||
start = Math.min(start, n - 1)
|
||||
for (end = start, bottom = top; end < n && bottom < far; end += 1) {
|
||||
bottom += height(end)
|
||||
}
|
||||
} else {
|
||||
while (start > 0 && top > near && known(start - 1) !== undefined) {
|
||||
start -= 1
|
||||
top -= known(start)!
|
||||
}
|
||||
while (start < end - 1 && top + height(start) < scrollTop - 2 * viewport) {
|
||||
top += height(start)
|
||||
start += 1
|
||||
}
|
||||
while (end < n && bottom < far) {
|
||||
bottom += height(end)
|
||||
end += 1
|
||||
}
|
||||
while (!following && end - 1 > start && bottom - height(end - 1) > scrollTop + 3 * viewport) {
|
||||
end -= 1
|
||||
bottom -= height(end)
|
||||
}
|
||||
}
|
||||
end = following ? n : Math.max(end, start + 1)
|
||||
return start === g.start && end === g.end ? null : { start, end }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure'
|
||||
import type { MobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import type { MobileNativeChatScrollRoots } from './mobile-native-chat-scroll-roots'
|
||||
|
||||
type Box = { top: number; bottom: number }
|
||||
type Measured = { getBoundingClientRect: () => Box }
|
||||
|
||||
export type MobileNativeChatWindowScroller = Measured & {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
clientWidth: number
|
||||
style?: { overflowAnchor: string }
|
||||
addEventListener: (
|
||||
type: string,
|
||||
listener: (event: unknown) => void,
|
||||
options: { passive: true }
|
||||
) => void
|
||||
removeEventListener: (type: string, listener: (event: unknown) => void) => void
|
||||
}
|
||||
|
||||
export type MobileNativeChatWindowRowState = (
|
||||
index: number,
|
||||
row: NativeChatMessage
|
||||
) => MobileNativeChatTurnRow
|
||||
export type MobileNativeChatWindowGroup = { from: string; fromAt: number; to: string; toAt: number }
|
||||
|
||||
/** Where the window stands, by key, so landings and trims never change which rows are drawn. */
|
||||
export type MobileNativeChatWindowSeat = {
|
||||
scope: string
|
||||
first: string
|
||||
firstAt: number
|
||||
last: string | null
|
||||
lastAt: number
|
||||
/** Measured rows trimmed from above a parked reader: their height stays until settled. */
|
||||
slack: number
|
||||
/** Rows drawn in place above the window only so they get measured. */
|
||||
measure: MobileNativeChatWindowGroup | null
|
||||
}
|
||||
|
||||
export type MobileNativeChatWindowDrawn = {
|
||||
keys: readonly string[]
|
||||
rows: readonly NativeChatMessage[]
|
||||
indexOf: (key: string | undefined) => number | undefined
|
||||
rowState: MobileNativeChatWindowRowState
|
||||
start: number
|
||||
end: number
|
||||
/** The first row in the document: rows above it were never measured. */
|
||||
first: number
|
||||
sourceHead: string | undefined
|
||||
following: boolean
|
||||
layoutKey: string
|
||||
}
|
||||
|
||||
export type MobileNativeChatPageWindowArgs = {
|
||||
roots?: MobileNativeChatScrollRoots
|
||||
rows: readonly NativeChatMessage[]
|
||||
rowKeys: Pick<MobileNativeChatRowKeys, 'scope' | 'sourceHead' | 'keyOf' | 'current'>
|
||||
rowState: MobileNativeChatWindowRowState
|
||||
following: boolean
|
||||
/** At event time, before a re-render lands. */
|
||||
isFollowing: () => boolean
|
||||
layoutKey: string
|
||||
scroller: () => MobileNativeChatWindowScroller | null | undefined
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createElement as h, Fragment, useMemo, type ReactElement, type ReactNode } from 'react'
|
||||
import {
|
||||
act,
|
||||
create,
|
||||
type ReactTestRenderer,
|
||||
type ReactTestRendererJSON as Node
|
||||
} from 'react-test-renderer'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import {
|
||||
useMobileNativeChatRowKeys,
|
||||
type MobileNativeChatRowKeys
|
||||
} from './mobile-native-chat-row-keys'
|
||||
import { MobileNativeChatScrollHold, mobileNativeChatRowId } from './MobileNativeChatScrollHold'
|
||||
import { ToolRun } from './MobileNativeChatToolRun'
|
||||
|
||||
const platform = vi.hoisted(() => ({ OS: 'web' }))
|
||||
// The page's own hold, as on iOS: where the browser has scroll anchoring it keeps these places
|
||||
// itself, and the hold runs only for a regroup or a move to another row.
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1'
|
||||
})
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
// A class, so an opened run's box can scroll it the way it scrolls a real ScrollView.
|
||||
class ScrollView extends React.Component<{ children?: ReactNode }> {
|
||||
scrollToEnd = (): void => {}
|
||||
scrollTo = (): void => {}
|
||||
render(): ReactNode {
|
||||
return React.createElement('div', null, this.props.children)
|
||||
}
|
||||
}
|
||||
const settled = () => ({ start: (): void => {}, stop: (): void => {} })
|
||||
return {
|
||||
Platform: platform,
|
||||
Text: 'span',
|
||||
View: 'div',
|
||||
Pressable: 'button',
|
||||
ScrollView,
|
||||
Animated: {
|
||||
Text: 'span',
|
||||
Value: class {
|
||||
setValue(): void {}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: settled,
|
||||
timing: settled
|
||||
},
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
useWindowDimensions: () => ({ width: 400, height: 800 })
|
||||
}
|
||||
})
|
||||
vi.mock('lucide-react-native', () => {
|
||||
const icon = () => null
|
||||
const names = ['Bot', 'Brain', 'Check', 'ChevronRight', 'Circle', 'CircleCheck', 'CircleDot']
|
||||
const more = ['Eye', 'FileMinus2', 'FilePen', 'FilePlus2', 'Folder', 'Globe', 'ListChecks']
|
||||
const rest = ['MessageSquareMore', 'Pencil', 'Plug', 'Search', 'SquareTerminal', 'Wrench']
|
||||
return Object.fromEntries([...names, ...more, ...rest].map((name) => [name, icon]))
|
||||
})
|
||||
vi.mock('./mobile-native-chat-tool-styles', () => ({
|
||||
toolStyles: {},
|
||||
toolType: () => ({ row: {}, meta: {}, code: {} })
|
||||
}))
|
||||
vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null }))
|
||||
vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() }))
|
||||
vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false }))
|
||||
|
||||
const say = (id: string, role: 'user' | 'assistant', text: string): NativeChatMessage => ({
|
||||
id,
|
||||
role,
|
||||
timestamp: null,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text }]
|
||||
})
|
||||
const edit = (n: number): NativeChatBlock => {
|
||||
const lines = (word: string): string => Array.from({ length: 30 }, () => word).join('\n')
|
||||
const input = { file_path: `f${n}.ts`, old_string: lines('old'), new_string: lines('new') }
|
||||
return { type: 'tool-call', name: 'Edit', callId: `c${n}`, input }
|
||||
}
|
||||
const run = (n: number): NativeChatMessage[] => [
|
||||
{ ...say(`a${n}`, 'assistant', ''), blocks: [edit(n)] },
|
||||
{ ...say(`r${n}`, 'user', ''), blocks: [{ type: 'tool-result', callId: `c${n}`, output: 'ok' }] }
|
||||
]
|
||||
// An earlier turn, then a reply working silently through 40 edits.
|
||||
const chat = [say('u0', 'user', 'first'), say('x0', 'assistant', 'answer'), say('u1', 'user', 'go')]
|
||||
.concat({
|
||||
...say('a1', 'assistant', 'On it'),
|
||||
blocks: [{ type: 'text', text: 'On it' }, edit(1)]
|
||||
})
|
||||
.concat(run(1).slice(1), ...Array.from({ length: 39 }, (_, i) => run(i + 2)))
|
||||
|
||||
type Box = { id: string; top: number; bottom: number; end: number }
|
||||
type Element = {
|
||||
getAttribute: () => string
|
||||
getBoundingClientRect: () => { top: number; bottom: number }
|
||||
querySelectorAll: (selector: string) => Element[]
|
||||
}
|
||||
|
||||
const oldHostTail = (): NativeChatMessage[] => JSON.parse(JSON.stringify(chat))
|
||||
const from = (id: string) => oldHostTail().slice(chat.findIndex((m) => m.id === id))
|
||||
|
||||
/** The page with one 20-tall line per text, as the list's scrollable node counting its writes. */
|
||||
function page(tree: () => Node | Node[] | null) {
|
||||
const boxes = (): Box[] => {
|
||||
const out: Box[] = []
|
||||
let y = 0
|
||||
const walk = (node: Node | string): void => {
|
||||
if (typeof node === 'string' || node.type === 'span') {
|
||||
y += typeof node === 'string' ? 0 : 20
|
||||
return
|
||||
}
|
||||
const at = out.push({ id: String(node.props.testID ?? ''), top: y, bottom: y, end: 0 }) - 1
|
||||
node.children?.forEach(walk)
|
||||
out[at] = { ...out[at]!, bottom: y, end: out.length }
|
||||
}
|
||||
walk({ type: 'page', props: {}, children: [tree() ?? []].flat() })
|
||||
return out
|
||||
}
|
||||
const select = (all: Box[], from: number, to: number, selector: string): Element[] => {
|
||||
const prefix = /\^="([^"]+)"/.exec(selector)?.[1] ?? '?'
|
||||
return all.flatMap((box, at) =>
|
||||
at >= from && at < to && box.id.startsWith(prefix)
|
||||
? [
|
||||
{
|
||||
getAttribute: () => box.id,
|
||||
getBoundingClientRect: () => ({
|
||||
top: box.top - node.scrollTop,
|
||||
bottom: box.bottom - node.scrollTop
|
||||
}),
|
||||
querySelectorAll: (inner: string) => select(all, at + 1, box.end, inner)
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
let scrollTop = 0
|
||||
const node = {
|
||||
writes: 0,
|
||||
get scrollTop(): number {
|
||||
return scrollTop
|
||||
},
|
||||
set scrollTop(value: number) {
|
||||
node.writes += 1
|
||||
scrollTop = value
|
||||
},
|
||||
clientHeight: 200,
|
||||
get scrollHeight(): number {
|
||||
return boxes()[0]?.bottom ?? 0
|
||||
},
|
||||
getAttribute: () => null,
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
|
||||
querySelectorAll: (selector: string): Element[] => select(boxes(), 0, Infinity, selector),
|
||||
top: (id: string): number =>
|
||||
(boxes().find((box) => box.id === id)?.top ?? Number.NaN) - node.scrollTop
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function Row({ message, rowKey }: { message: NativeChatMessage; rowKey: string }): ReactElement {
|
||||
const { prose, tools } = splitNativeChatBlocks(message.blocks)
|
||||
const words = prose.map((block, i) =>
|
||||
h('span', { key: i }, block.type === 'text' ? block.text : '')
|
||||
)
|
||||
const run =
|
||||
tools.length > 0 && h(ToolRun, { blocks: tools, defaultExpanded: false, expandChildren: false })
|
||||
return h('div', { testID: mobileNativeChatRowId(rowKey) }, words, run)
|
||||
}
|
||||
|
||||
type Scroller = ReturnType<typeof page>
|
||||
|
||||
let shownKeys!: MobileNativeChatRowKeys
|
||||
|
||||
function Chat(props: {
|
||||
messages: NativeChatMessage[]
|
||||
following: boolean
|
||||
live: boolean
|
||||
listRef: { current: { getScrollableNode: () => Scroller } }
|
||||
}): ReactElement {
|
||||
const folded = useMemo(
|
||||
() => foldMobileNativeChatMessages(props.messages, 'claude'),
|
||||
[props.messages]
|
||||
)
|
||||
const keys = useMobileNativeChatRowKeys(folded, props.messages, 'chat')
|
||||
shownKeys = keys
|
||||
return h(
|
||||
Fragment,
|
||||
null,
|
||||
h(MobileNativeChatScrollHold, {
|
||||
rowKeys: keys,
|
||||
isFollowing: () => props.following,
|
||||
listRef: props.listRef
|
||||
}),
|
||||
h('div', { testID: 'header' }, h('span', null, 'Load earlier')),
|
||||
folded.map((item) =>
|
||||
h(Row, { key: keys.keyOf(item), message: item, rowKey: keys.keyOf(item) })
|
||||
),
|
||||
props.live ? h('span', null, 'Working') : null
|
||||
)
|
||||
}
|
||||
|
||||
function mount(messages: NativeChatMessage[], options = { following: false, live: false }) {
|
||||
let renderer!: ReactTestRenderer
|
||||
act(() => {
|
||||
renderer = create(h('div'))
|
||||
})
|
||||
const scroller = page(() => renderer.toJSON())
|
||||
const listRef = { current: { getScrollableNode: () => scroller } }
|
||||
const show = (next: NativeChatMessage[]): number => {
|
||||
const before = scroller.writes
|
||||
act(() => renderer.update(h(Chat, { ...options, messages: next, listRef })))
|
||||
return scroller.writes - before
|
||||
}
|
||||
show(messages)
|
||||
const find = (id: string) => renderer.root.find((n) => n.props.testID === id)
|
||||
const press = (id: string) => act(() => find(id).findAllByType('button')[0]!.props.onPress())
|
||||
const drawn = (id: string) => renderer.root.findAll((n) => n.props.testID === id).length
|
||||
const lines = (id: string) => find(id).findAllByType('span').length
|
||||
const rows = () =>
|
||||
renderer.root
|
||||
.findAll((n) => n.type === 'div' && String(n.props.testID).startsWith('chat-row:'))
|
||||
.map((n) => n.props.testID)
|
||||
return { scroller, show, press, drawn, lines, find, rows }
|
||||
}
|
||||
|
||||
it('keeps the line a reader was on, its run open, when older messages join the run from an old host', () => {
|
||||
// On a line inside the run, or at the top beside Load earlier while the window opens on a result
|
||||
// that the run's older calls take in.
|
||||
for (const atTop of [false, true]) {
|
||||
const chat0 = mount(from('r31'))
|
||||
chat0.press(mobileNativeChatRowId('a32'))
|
||||
chat0.press('chat-member:call:c34')
|
||||
const opened = chat0.lines('chat-member:call:c34')
|
||||
chat0.scroller.scrollTop += chat0.scroller.top('chat-member:call:c34')
|
||||
// An old host ignores the cursor and sends the whole tail again: new objects, the same ids.
|
||||
for (const [older, first] of [
|
||||
['r21', 'c32'],
|
||||
['u0', 'c22']
|
||||
] as const) {
|
||||
if (atTop) {
|
||||
chat0.scroller.scrollTop = 0
|
||||
}
|
||||
const line = `chat-member:call:${atTop ? first : 'c34'}`
|
||||
const top = chat0.scroller.top(line)
|
||||
expect(chat0.show(from(older))).toBeLessThanOrEqual(1)
|
||||
expect(chat0.scroller.top(line)).toBe(top)
|
||||
}
|
||||
|
||||
// The run keeps its row, so the earlier turn sits above it and the run stays open.
|
||||
expect(chat0.rows().slice(0, 3)).toEqual(['u0', 'x0', 'u1'].map(mobileNativeChatRowId))
|
||||
expect(chat0.lines('chat-member:call:c34')).toBe(opened)
|
||||
expect(chat0.drawn('chat-member:call:c1')).toBe(1)
|
||||
// The result row the window opened on is now drawn by the run.
|
||||
expect(shownKeys.current('r21')).toBe('a32')
|
||||
|
||||
// A reply and a send land after the run: what the reader had loaded stays drawn.
|
||||
chat0.show([...oldHostTail(), say('done', 'assistant', 'Done'), say('ask', 'user', 'next')])
|
||||
expect(chat0.drawn(mobileNativeChatRowId('a32'))).toBe(1)
|
||||
expect(chat0.drawn('chat-member:call:c34')).toBe(1)
|
||||
}
|
||||
|
||||
// The rows after the run stay put: collapsed, its header holds where it was, not in the result
|
||||
// row's place; open, the line on screen holds while older calls join the run above it.
|
||||
const later = Array.from({ length: 10 }, (_, i) => say(`t${i}`, 'user', 'later'))
|
||||
for (const open of [false, true]) {
|
||||
const tail = mount([...from('r21'), ...later])
|
||||
if (open) {
|
||||
tail.press(mobileNativeChatRowId('a22'))
|
||||
tail.scroller.scrollTop += tail.scroller.top('chat-member:call:c27') + 40
|
||||
}
|
||||
const before = tail.scroller.top(mobileNativeChatRowId('t0'))
|
||||
tail.show([...oldHostTail(), ...later])
|
||||
expect(tail.scroller.top(mobileNativeChatRowId('t0'))).toBe(before)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the view to iOS while it bounces the list past the top, and to a native list', () => {
|
||||
for (const [os, scrollTop] of [
|
||||
['web', -15],
|
||||
['ios', 40]
|
||||
] as const) {
|
||||
platform.OS = os
|
||||
const chat0 = mount(from('r21'))
|
||||
chat0.press(mobileNativeChatRowId('a22'))
|
||||
chat0.scroller.scrollTop = scrollTop
|
||||
expect(chat0.show(oldHostTail())).toBe(0)
|
||||
}
|
||||
platform.OS = 'web'
|
||||
})
|
||||
|
||||
it('at the cap, a trim and an append of equal height hold a parked reader and leave a following one to follow', () => {
|
||||
const rows = Array.from({ length: 13 }, (_, i) =>
|
||||
say(`m${i}`, i % 2 ? 'assistant' : 'user', `message ${i}`)
|
||||
)
|
||||
for (const options of [
|
||||
{ following: true, live: false },
|
||||
{ following: true, live: true },
|
||||
{ following: false, live: false }
|
||||
]) {
|
||||
const chat0 = mount(rows.slice(0, 12), options)
|
||||
chat0.scroller.scrollTop = options.following ? chat0.scroller.scrollHeight - 200 : 50
|
||||
const before = {
|
||||
scrollTop: chat0.scroller.scrollTop,
|
||||
m4: chat0.scroller.top(mobileNativeChatRowId('m4'))
|
||||
}
|
||||
|
||||
const writes = chat0.show(rows.slice(1))
|
||||
|
||||
expect(writes).toBe(options.following ? 0 : 1)
|
||||
if (options.following) {
|
||||
expect(chat0.scroller.scrollTop).toBe(before.scrollTop)
|
||||
} else {
|
||||
expect(chat0.scroller.top(mobileNativeChatRowId('m4'))).toBe(before.m4)
|
||||
}
|
||||
}
|
||||
|
||||
// Trims split the top run: sequential calls leave a result above it; parallel calls (each call,
|
||||
// then each output, or all outputs in one message) leave results below it. Results never take
|
||||
// the run's key.
|
||||
const output = (n: number): NativeChatMessage => ({
|
||||
...say(`o${n}`, 'user', ''),
|
||||
role: 'tool',
|
||||
blocks: [{ type: 'tool-result', callId: `c${n}`, output: 'ok' }]
|
||||
})
|
||||
const six = [1, 2, 3, 4, 5, 6]
|
||||
const calls = (id: string) =>
|
||||
six.map((n) => ({ ...say(`${id}${n}`, 'assistant', ''), blocks: [edit(n)] }))
|
||||
const outputs = { ...output(1), id: 'U', blocks: six.flatMap((n) => output(n).blocks) }
|
||||
for (const [source, head, orphan, last, absorbed] of [
|
||||
[chat.slice(3, 15), 'a1', 'r1', 11, []],
|
||||
[calls('p').concat(six.map(output)), 'p1', 'o1', 6, ['o1', 'o2', 'o3', 'o4']],
|
||||
[calls('q').concat(outputs), 'q1', 'U', 6, ['U']]
|
||||
] as const) {
|
||||
const top = mount([...source, ...rows])
|
||||
top.press(mobileNativeChatRowId(head))
|
||||
top.scroller.scrollTop = top.scroller.top('chat-member:call:c6') + top.scroller.scrollTop - 10
|
||||
const trim = (trimmed: number) => {
|
||||
top.show([...source.slice(trimmed), ...rows, say(`done${trimmed}`, 'assistant', 'Done')])
|
||||
const run = top.find(mobileNativeChatRowId(head))
|
||||
expect(run.findAll((n) => n.props.testID === 'chat-member:call:c6')).toHaveLength(1)
|
||||
expect(top.scroller.top('chat-member:call:c6')).toBe(10)
|
||||
}
|
||||
trim(1)
|
||||
expect(top.drawn(mobileNativeChatRowId(orphan))).toBe(1)
|
||||
// A second single trim, then two at once.
|
||||
trim(2)
|
||||
trim(4)
|
||||
// Paging back folds every result that was split off into the run again.
|
||||
top.show([...source, ...rows, say('done4', 'assistant', 'Done')])
|
||||
expect(absorbed.map(shownKeys.current)).toEqual(absorbed.map(() => head))
|
||||
// Once its last call leaves, the run is gone; its outputs don't take its key.
|
||||
top.show([...source.slice(last), ...rows, say('done', 'assistant', 'Done')])
|
||||
expect(top.drawn(mobileNativeChatRowId(head))).toBe(0)
|
||||
}
|
||||
|
||||
// A trim takes the very line the reader is on: the next one keeps its place.
|
||||
const reader = mount([...chat.slice(3, 15), ...rows])
|
||||
reader.press(mobileNativeChatRowId('a1'))
|
||||
reader.scroller.scrollTop += reader.scroller.top('chat-member:call:c1')
|
||||
const next = reader.scroller.top('chat-member:call:c2')
|
||||
expect(next).toBeGreaterThan(0)
|
||||
reader.show([...chat.slice(4, 15), ...rows, say('done', 'assistant', 'Done')])
|
||||
expect(reader.scroller.top('chat-member:call:c2')).toBe(next)
|
||||
})
|
||||
|
||||
it('a roster keeps its key when the fold moves it, and the rows the reader saw after it stay put', () => {
|
||||
const sibling = (id: string): NativeChatMessage => ({
|
||||
...say(id, 'user', ''),
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{ type: 'text', text: `Ran subagent ${id}` },
|
||||
{ type: 'subagent', id: `agent-${id}`, label: id, state: 'completed', groupId: 'turn-1' }
|
||||
]
|
||||
})
|
||||
const said = (name: string) =>
|
||||
Array.from({ length: 12 }, (_, i) => say(`${name}${i}`, i % 2 ? 'assistant' : 'user', name))
|
||||
const all = [...said('old'), sibling('s1'), ...said('mid'), sibling('s2'), ...said('new')]
|
||||
const chat0 = mount(all.slice(25))
|
||||
const tops = (ids: string[]) => ids.map((id) => chat0.scroller.top(mobileNativeChatRowId(id)))
|
||||
// The roster first on screen: an older sibling joining moves it up to that sibling's place.
|
||||
chat0.scroller.scrollTop = 20
|
||||
const below = tops(['new0', 'new5'])
|
||||
expect(chat0.show(all)).toBeLessThanOrEqual(1)
|
||||
expect(tops(['new0', 'new5'])).toEqual(below)
|
||||
expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1)
|
||||
// And back: a trim of the first sibling moves the roster down to the second's place.
|
||||
chat0.scroller.scrollTop += chat0.scroller.top(mobileNativeChatRowId('s2'))
|
||||
const after = tops(['mid0', 'mid5'])
|
||||
expect(chat0.show(all.slice(13))).toBeLessThanOrEqual(1)
|
||||
expect(tops(['mid0', 'mid5'])).toEqual(after)
|
||||
expect(chat0.drawn(mobileNativeChatRowId('s2'))).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
mobileNativeChatRunDescriptors,
|
||||
type MobileNativeChatRunPresentation
|
||||
} from './mobile-native-chat-run-descriptors'
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatMessage
|
||||
} from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Where each drawn row sits on the page. A folded row's id is its first message's, so older
|
||||
* messages joining a run would replace the row; its key stays while what it is made of draws. */
|
||||
export type MobileNativeChatRowKeys = {
|
||||
/** The chat the keys belong to; nothing they held carries into another. */
|
||||
scope: string
|
||||
/** The source window's first message: it changes when a page, a cap trim or a replacement lands,
|
||||
* even while every row keeps its key. */
|
||||
sourceHead: string | undefined
|
||||
keyOf: (message: NativeChatMessage) => string
|
||||
/** The key now drawing what a key of the last render drew; undefined once all of it left. */
|
||||
current: (key: string) => string | undefined
|
||||
/** The folded message a key's row is drawn for, which sets where the row sits: a roster keeps its
|
||||
* key while the fold moves it to an older sibling's place. */
|
||||
placement: (key: string) => string
|
||||
}
|
||||
|
||||
export type MobileNativeChatRowSources = { identity: string[]; content: string[] }
|
||||
type Sources = MobileNativeChatRowSources
|
||||
|
||||
export type MobileNativeChatAssignedRows = {
|
||||
rows: readonly NativeChatMessage[]
|
||||
messages: readonly NativeChatMessage[]
|
||||
keyById: ReadonlyMap<string, string>
|
||||
/** Each row's sources, when that render worked them out. */
|
||||
sources?: readonly Sources[]
|
||||
prepareSources?: () => readonly Sources[]
|
||||
}
|
||||
type Assigned = MobileNativeChatAssignedRows
|
||||
|
||||
/** The fold keeps its sources' block objects, so a block names the message it came from. */
|
||||
function blockOwners(messages: readonly NativeChatMessage[]): Map<NativeChatBlock, string> {
|
||||
const owners = new Map<NativeChatBlock, string>()
|
||||
for (const message of messages) {
|
||||
for (const block of message.blocks) {
|
||||
if (!owners.has(block)) {
|
||||
owners.set(block, message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
/** What a row draws, by message and kind, and which of it makes the row what it is: a run is
|
||||
* its assistant and its calls. Its results are content only, so a result left on a row of its
|
||||
* own never takes the run's key. The fold makes a roster's group block; its group stands in. */
|
||||
function sourcesOf(row: NativeChatMessage, owners: Map<NativeChatBlock, string>): Sources {
|
||||
const own = new Set<string>()
|
||||
const results = new Set<string>()
|
||||
for (const block of row.blocks) {
|
||||
const owner = owners.get(block)
|
||||
if (isSubagentGroupBlock(block)) {
|
||||
own.add(`subagent-group:${block.groupId}`)
|
||||
} else if (owner !== undefined && isToolResultBlock(block)) {
|
||||
results.add(`result:${owner}`)
|
||||
} else if (owner !== undefined) {
|
||||
own.add(`body:${owner}`)
|
||||
}
|
||||
}
|
||||
const content = [...own, ...results]
|
||||
const identity = own.size > 0 ? [...own] : results.size > 0 ? [...results] : [`body:${row.id}`]
|
||||
return { identity, content: content.length > 0 ? content : identity }
|
||||
}
|
||||
|
||||
/** Physical rows and displayed runs attribute against the same raw snapshot, only when needed. */
|
||||
export function prepareMobileNativeChatRowSources(messages: readonly NativeChatMessage[]) {
|
||||
let owners: Map<NativeChatBlock, string> | undefined
|
||||
const cache = new WeakMap<NativeChatMessage, Sources>()
|
||||
return (row: NativeChatMessage): Sources => {
|
||||
let sources = cache.get(row)
|
||||
if (!sources) {
|
||||
sources = sourcesOf(row, (owners ??= blockOwners(messages)))
|
||||
cache.set(row, sources)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
}
|
||||
|
||||
function sourcesByRow(
|
||||
rows: readonly NativeChatMessage[],
|
||||
messages: readonly NativeChatMessage[],
|
||||
scopeOf?: (row: NativeChatMessage) => string | undefined
|
||||
): Sources[] {
|
||||
const owners = blockOwners(messages)
|
||||
return rows.map((row) => {
|
||||
const sources = sourcesOf(row, owners)
|
||||
if (!scopeOf) {
|
||||
return sources
|
||||
}
|
||||
const scoped = (source: string) => `${source}\0${scopeOf(row) ?? ''}`
|
||||
return { identity: sources.identity.map(scoped), content: sources.content.map(scoped) }
|
||||
})
|
||||
}
|
||||
|
||||
export function assignMobileNativeChatRowKeys(
|
||||
previous: Assigned | null,
|
||||
rows: readonly NativeChatMessage[],
|
||||
messages: readonly NativeChatMessage[],
|
||||
fresh?: () => string,
|
||||
scopeOf?: (row: NativeChatMessage) => string | undefined,
|
||||
preparedSources?: () => readonly Sources[]
|
||||
): Pick<Assigned, 'sources'> & {
|
||||
keyById: Map<string, string>
|
||||
moved: Map<string, string | undefined>
|
||||
} {
|
||||
const keyById = new Map<string, string>()
|
||||
const taken = new Set<string>()
|
||||
const take = (row: NativeChatMessage, key: string): void => {
|
||||
keyById.set(row.id, key)
|
||||
taken.add(key)
|
||||
}
|
||||
const ids = new Set(rows.map((row) => row.id))
|
||||
let before: readonly Sources[] = []
|
||||
let now: readonly Sources[] | undefined
|
||||
const current = () => (now ??= preparedSources?.() ?? sourcesByRow(rows, messages, scopeOf))
|
||||
if (fresh) {
|
||||
current()
|
||||
}
|
||||
if (!fresh && previous?.rows.every((row) => ids.has(row.id))) {
|
||||
// Nothing left: every row keeps its key.
|
||||
for (const row of rows) {
|
||||
const key = previous.keyById.get(row.id)
|
||||
if (key !== undefined) {
|
||||
take(row, key)
|
||||
}
|
||||
}
|
||||
} else if (previous) {
|
||||
before =
|
||||
previous.sources ??
|
||||
previous.prepareSources?.() ??
|
||||
sourcesByRow(previous.rows, previous.messages)
|
||||
current()
|
||||
const was = new Map<string, number>()
|
||||
before.forEach(({ identity }, at) => identity.forEach((source) => was.set(source, at)))
|
||||
// A row continues the one row its identity came from, if that row's went nowhere else. Two
|
||||
// would be one key for two rows, so neither inherits rather than one being picked by position.
|
||||
const origins = current().map(
|
||||
({ identity }) => new Set(identity.flatMap((s) => was.get(s) ?? []))
|
||||
)
|
||||
const heirs = new Map<number, number>()
|
||||
origins.forEach((from) => from.forEach((at) => heirs.set(at, (heirs.get(at) ?? 0) + 1)))
|
||||
origins.forEach((from, at) => {
|
||||
const [origin] = from
|
||||
const only = origin !== undefined && from.size === 1 && heirs.get(origin) === 1
|
||||
const key = only ? previous.keyById.get(previous.rows[origin]!.id) : undefined
|
||||
if (key !== undefined) {
|
||||
take(rows[at]!, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
for (const row of rows.filter((unkeyed) => !keyById.has(unkeyed.id))) {
|
||||
let key = fresh?.() ?? row.id
|
||||
for (let n = 1; taken.has(key); n += 1) {
|
||||
key = `${row.id}~${n}`
|
||||
}
|
||||
take(row, key)
|
||||
}
|
||||
// A key no row kept names the first row that took in what it drew. Results left on rows of their
|
||||
// own when a run's last call went name none: the run ended, and its key goes with it.
|
||||
const drawnAt = new Map<string, number>()
|
||||
now?.forEach(({ identity, content }, at) => {
|
||||
if (!identity[0]!.startsWith('result:')) {
|
||||
content.forEach((s) => {
|
||||
if (!drawnAt.has(s)) {
|
||||
drawnAt.set(s, at)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const moved = new Map<string, string | undefined>()
|
||||
before.forEach(({ content }, at) => {
|
||||
const key = previous?.keyById.get(previous.rows[at]!.id)
|
||||
if (key !== undefined && !taken.has(key)) {
|
||||
const holder = rows[Math.min(...content.map((s) => drawnAt.get(s) ?? Infinity))]
|
||||
moved.set(key, holder && keyById.get(holder.id))
|
||||
}
|
||||
})
|
||||
return { keyById, moved, ...(now ? { sources: now } : {}) }
|
||||
}
|
||||
|
||||
/** Row keys for one shown chat, against the last committed render only, so what a key held dies
|
||||
* with the rows that held it. */
|
||||
export function useMobileNativeChatRowKeys(
|
||||
rows: readonly NativeChatMessage[],
|
||||
messages: readonly NativeChatMessage[],
|
||||
scope: string,
|
||||
presentation?: MobileNativeChatRunPresentation
|
||||
): MobileNativeChatRowKeys & {
|
||||
runOwners: ReadonlyMap<string, string>
|
||||
runPlacements: ReadonlyMap<string, string>
|
||||
} {
|
||||
// Written at commit: a render React discards must not become the next one's last render.
|
||||
const committed = useRef<
|
||||
(Assigned & { scope: string; displayed: Assigned; serial: number }) | null
|
||||
>(null)
|
||||
const assigned = useMemo(() => {
|
||||
const previous = committed.current?.scope === scope ? committed.current : null
|
||||
const sourceOf = prepareMobileNativeChatRowSources(messages)
|
||||
const prepareSources = () => rows.map(sourceOf)
|
||||
const physical = assignMobileNativeChatRowKeys(
|
||||
previous,
|
||||
rows,
|
||||
messages,
|
||||
undefined,
|
||||
undefined,
|
||||
prepareSources
|
||||
)
|
||||
const runs = presentation ? mobileNativeChatRunDescriptors(presentation, sourceOf) : []
|
||||
const heads = runs.map((run) => run.row)
|
||||
let serial = previous?.serial ?? 0
|
||||
const displayed = {
|
||||
rows: heads,
|
||||
messages,
|
||||
...assignMobileNativeChatRowKeys(
|
||||
previous?.displayed ?? null,
|
||||
heads,
|
||||
messages,
|
||||
() => `display-run:${serial++}`,
|
||||
undefined,
|
||||
() => runs.map((run) => run.sources)
|
||||
)
|
||||
}
|
||||
const runPlacements = new Map(Array.from(displayed.keyById, ([row, owner]) => [owner, row]))
|
||||
return { scope, rows, messages, prepareSources, ...physical, displayed, serial, runPlacements }
|
||||
}, [rows, messages, scope, presentation])
|
||||
useLayoutEffect(() => {
|
||||
committed.current = assigned
|
||||
}, [assigned])
|
||||
return useMemo(() => {
|
||||
let idByKey: Map<string, string> | undefined
|
||||
return {
|
||||
runOwners: assigned.displayed.keyById,
|
||||
runPlacements: assigned.runPlacements,
|
||||
scope: assigned.scope,
|
||||
sourceHead: assigned.messages[0]?.id,
|
||||
keyOf: (message: NativeChatMessage) => assigned.keyById.get(message.id) ?? message.id,
|
||||
current: (key: string) => (assigned.moved.has(key) ? assigned.moved.get(key) : key),
|
||||
placement: (key: string) =>
|
||||
(idByKey ??= new Map(Array.from(assigned.keyById, ([id, k]) => [k, id]))).get(key) ?? key
|
||||
}
|
||||
}, [assigned])
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run'
|
||||
import { nativeChatAskRunBlocks } from '../../../src/shared/native-chat-ask-row'
|
||||
import type { MobileNativeChatWorkRuns } from './use-mobile-native-chat-transcript-rows'
|
||||
import type { MobileNativeChatRowSources } from './mobile-native-chat-row-keys'
|
||||
|
||||
export type MobileNativeChatRunPresentation = {
|
||||
rows: readonly NativeChatMessage[]
|
||||
workRuns: MobileNativeChatWorkRuns
|
||||
turnKeys: readonly (string | undefined)[]
|
||||
receipts?: ReadonlyMap<string, NativeChatResolvedPrompt> | null
|
||||
}
|
||||
|
||||
/** Describe the runs the existing presentation draws, sharing physical-row source attribution. */
|
||||
export function mobileNativeChatRunDescriptors(
|
||||
{ rows, workRuns, turnKeys, receipts }: MobileNativeChatRunPresentation,
|
||||
sourceOf: (row: NativeChatMessage) => MobileNativeChatRowSources
|
||||
) {
|
||||
return rows.flatMap((row, index) => {
|
||||
if (workRuns.joined.has(index) || receipts?.has(row.id)) {
|
||||
return []
|
||||
}
|
||||
const members = workRuns.heads.get(index)
|
||||
if (row.role === 'reasoning' && !members) {
|
||||
return []
|
||||
}
|
||||
const tools = members
|
||||
? nativeChatWorkRunEntries(members).blocks
|
||||
: splitNativeChatBlocks(row.blocks).tools
|
||||
if (nativeChatAskRunBlocks(tools).work.length === 0) {
|
||||
return []
|
||||
}
|
||||
const content = [...new Set((members ?? [row]).flatMap((member) => sourceOf(member).content))]
|
||||
const own = content.filter((source) => !source.startsWith('result:'))
|
||||
const scoped = (source: string) => `${source}\0${turnKeys[index] ?? ''}`
|
||||
return [
|
||||
{
|
||||
row,
|
||||
sources: {
|
||||
identity: (own.length ? own : content).map(scoped),
|
||||
content: content.map(scoped)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
act,
|
||||
createElement as h,
|
||||
Fragment,
|
||||
startTransition,
|
||||
Suspense,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import { MobileNativeChatScrollHold } from './MobileNativeChatScrollHold'
|
||||
import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows'
|
||||
import { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion'
|
||||
import {
|
||||
mobileNativeChatScrollFrame,
|
||||
mobileNativeChatReadingParts,
|
||||
mobileNativeChatVisible,
|
||||
MobileNativeChatScrollRoots
|
||||
} from './mobile-native-chat-scroll-roots'
|
||||
import {
|
||||
MobileNativeChatDisclosureContext,
|
||||
useMobileNativeChatDisclosures,
|
||||
type MobileNativeChatDisclosureStore
|
||||
} from './mobile-native-chat-disclosure-store'
|
||||
import { useMobileNativeChatRowKeys } from './mobile-native-chat-row-keys'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const View = (p: {
|
||||
children?: ReactNode
|
||||
testID?: string
|
||||
onPress?: () => void
|
||||
accessibilityLiveRegion?: string
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': p.testID, onClick: p.onPress, 'data-live': p.accessibilityLiveRegion },
|
||||
p.children
|
||||
)
|
||||
const ScrollView = React.forwardRef((p: { children?: ReactNode }, ref) => {
|
||||
const node = React.useRef<HTMLDivElement>(null)
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getScrollableNode: () => node.current,
|
||||
getInnerViewNode: () => node.current?.firstElementChild
|
||||
}))
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ ref: node, className: 'viewport' },
|
||||
React.createElement('div', null, p.children)
|
||||
)
|
||||
})
|
||||
const animation = () => ({ start() {}, stop() {} })
|
||||
return {
|
||||
View,
|
||||
Text: View,
|
||||
Pressable: View,
|
||||
ScrollView,
|
||||
Image: View,
|
||||
Platform: { OS: 'web' },
|
||||
StyleSheet: { create: (s: unknown) => s, hairlineWidth: 1 },
|
||||
useWindowDimensions: () => ({ width: 400, height: 800 }),
|
||||
Animated: {
|
||||
Text: View,
|
||||
Value: class {
|
||||
setValue() {}
|
||||
},
|
||||
loop: animation,
|
||||
sequence: animation,
|
||||
timing: animation
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('lucide-react-native', () =>
|
||||
Object.fromEntries(
|
||||
'ArrowUp Bot Brain Check ChevronDown ChevronRight Copy Eye FileMinus2 FilePen FilePlus2 Folder Globe ListChecks MessageSquareMore Pencil Plug Search SquareChevronRight SquareTerminal Wrench Circle CircleCheck CircleDot'
|
||||
.split(' ')
|
||||
.map((key) => [key, () => null])
|
||||
)
|
||||
)
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null }))
|
||||
vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: () => null }))
|
||||
vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() }))
|
||||
vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false }))
|
||||
|
||||
let renderer: Root | undefined
|
||||
let container: HTMLDivElement
|
||||
let page: HTMLElement
|
||||
let frameShift = 0
|
||||
let roots: MobileNativeChatScrollRoots
|
||||
let owners: (string | undefined)[]
|
||||
let projected: (string | undefined)[]
|
||||
let store: MobileNativeChatDisclosureStore
|
||||
const abandoned = new Promise<void>(() => {})
|
||||
const correctPage = vi.fn((drift: number) => {
|
||||
page.scrollTop += drift
|
||||
})
|
||||
const msg = (
|
||||
id: string,
|
||||
blocks: NativeChatMessage['blocks'],
|
||||
role: NativeChatMessage['role'] = 'assistant'
|
||||
): NativeChatMessage => ({ id, blocks, role, timestamp: null, source: 'transcript' })
|
||||
const a = msg(
|
||||
'a',
|
||||
Array.from({ length: 6 }, (_, i) => ({
|
||||
type: 'tool-call',
|
||||
callId: `c${i}`,
|
||||
name: 'Bash',
|
||||
input: { command: `command ${i} ${'argument '.repeat(20)}` }
|
||||
}))
|
||||
)
|
||||
const thought = msg('r', [{ type: 'text', text: 'reason' }], 'reasoning')
|
||||
const user = msg('u', [{ type: 'text', text: 'go' }], 'user')
|
||||
const b = msg('b', [{ type: 'tool-call', callId: 'b', name: 'Bash', input: { command: 'b' } }])
|
||||
function reader(node: Element, top: number) {
|
||||
node.dispatchEvent(new Event('touchstart'))
|
||||
node.scrollTop = top
|
||||
node.dispatchEvent(new Event('scroll'))
|
||||
node.dispatchEvent(new Event('touchend'))
|
||||
}
|
||||
function members(root: Element): Element[] {
|
||||
return Array.from(root.querySelectorAll('[data-testid^="chat-member:"]')).filter(
|
||||
(node) => !mobileNativeChatScrollFrame(node, root)
|
||||
)
|
||||
}
|
||||
function rect(node: Element): DOMRect {
|
||||
const viewport = node.closest('[data-chat-scroll-root]')
|
||||
let top = 0
|
||||
let height = 1000
|
||||
if (node === page) {
|
||||
return DOMRect.fromRect({ y: 100, height: 200, width: 400 })
|
||||
}
|
||||
if (viewport === node) {
|
||||
const parent = node.parentElement?.closest('[data-chat-scroll-root]')
|
||||
top = parent
|
||||
? rect(node.parentElement!.closest('[data-testid^="chat-member:"]') ?? parent).top + 20
|
||||
: 40 + frameShift - page.scrollTop
|
||||
height = 160
|
||||
} else if (viewport) {
|
||||
const member = node.closest('[data-testid^="chat-member:"]')
|
||||
top =
|
||||
rect(viewport).top +
|
||||
Math.max(0, members(viewport).indexOf(member ?? node)) * 60 -
|
||||
viewport.scrollTop
|
||||
height = 60
|
||||
} else if (node.closest('[data-live], [data-testid="chat-row:u"]')) {
|
||||
top = -page.scrollTop
|
||||
height = 20
|
||||
} else {
|
||||
top = -page.scrollTop
|
||||
}
|
||||
return DOMRect.fromRect({ y: top, height, width: 400 })
|
||||
}
|
||||
function Shift({ value }: { value: number }): null {
|
||||
useLayoutEffect(() => {
|
||||
frameShift = value
|
||||
}, [value])
|
||||
return null
|
||||
}
|
||||
function Chat({
|
||||
rows,
|
||||
tools = true,
|
||||
shown = true,
|
||||
scope = 'chat',
|
||||
shift = 0,
|
||||
suspend = false,
|
||||
structured = true,
|
||||
working = false
|
||||
}: {
|
||||
rows: NativeChatMessage[]
|
||||
tools?: boolean
|
||||
shown?: boolean
|
||||
scope?: string
|
||||
shift?: number
|
||||
suspend?: boolean
|
||||
structured?: boolean
|
||||
working?: boolean
|
||||
}) {
|
||||
const current = useMobileNativeChatDisclosures(scope)
|
||||
const drawn = useMemo(() => (structured ? rows : foldToolMessages(rows)), [rows, structured])
|
||||
const turns = useMobileNativeChatTurnDisclosure({
|
||||
messages: drawn,
|
||||
enabled: structured,
|
||||
isWorking: working,
|
||||
scopeKey: scope
|
||||
})
|
||||
const keys = useMobileNativeChatRowKeys(drawn, rows, scope, turns.presentation)
|
||||
const ref = useRef({
|
||||
getScrollableNode: () => page,
|
||||
keepInPlace: correctPage
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
store = current
|
||||
roots = current.roots
|
||||
owners = drawn.map((row) => keys.runOwners.get(row.id))
|
||||
})
|
||||
if (suspend) {
|
||||
projected = drawn.map((row) => keys.runOwners.get(row.id))
|
||||
throw abandoned
|
||||
}
|
||||
return h(
|
||||
MobileNativeChatDisclosureContext.Provider,
|
||||
{ value: current },
|
||||
h(
|
||||
MobileNativeChatScrollHold,
|
||||
{
|
||||
rowKeys: keys,
|
||||
roots: current.roots,
|
||||
runPlacements: keys.runPlacements,
|
||||
layoutKey: String(tools),
|
||||
listRef: ref,
|
||||
isFollowing: () => false
|
||||
},
|
||||
h(Shift, { value: shift }),
|
||||
h(
|
||||
Fragment,
|
||||
{ key: scope },
|
||||
shown &&
|
||||
drawn.map((message, i) =>
|
||||
h(MobileNativeChatMessage, {
|
||||
key: keys.keyOf(message),
|
||||
message,
|
||||
rowKey: keys.keyOf(message),
|
||||
...turns.resolveRow(i, message),
|
||||
runOwner: keys.runOwners.get(message.id),
|
||||
structuredActivityUi: structured,
|
||||
toolsExpanded: tools
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
function mount(structured = true) {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
||||
vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Safari' })
|
||||
container = document.createElement('div')
|
||||
page = document.createElement('main')
|
||||
page.style.overflowY = 'auto'
|
||||
document.body.append(page)
|
||||
page.append(container)
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
return rect(this)
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(160)
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockImplementation(function (this: Element) {
|
||||
return this.getAttribute('data-short')
|
||||
? 80
|
||||
: members(this).length * 60 +
|
||||
400 +
|
||||
this.querySelectorAll('.viewport').length * 60 +
|
||||
Number(this.getAttribute('data-growth') ?? 0)
|
||||
})
|
||||
renderer = createRoot(container)
|
||||
return (rows: NativeChatMessage[], rest: Partial<Parameters<typeof Chat>[0]> = {}) => {
|
||||
const render = () =>
|
||||
renderer!.render(h(Suspense, { fallback: null }, h(Chat, { rows, structured, ...rest })))
|
||||
act(() => (rest.suspend ? startTransition(render) : render()))
|
||||
act(() => vi.advanceTimersByTime(20))
|
||||
}
|
||||
}
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
page?.remove()
|
||||
renderer = undefined
|
||||
frameShift = 0
|
||||
correctPage.mockClear()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps real structured runs and nested offsets under their own viewport across reheading', () => {
|
||||
const draw = mount()
|
||||
draw([user, a])
|
||||
const owner = owners[1]
|
||||
const run = container.querySelector('[data-chat-scroll-root]')
|
||||
if (!(run instanceof HTMLElement)) {
|
||||
throw new Error('missing run viewport')
|
||||
}
|
||||
const detail = run.querySelector('[data-chat-scroll-root]')
|
||||
if (!(detail instanceof HTMLElement)) {
|
||||
throw new Error('missing command viewport')
|
||||
}
|
||||
const row = run.closest('[data-testid^="chat-row:"]')!
|
||||
const rows = new MobileNativeChatPageRows()
|
||||
rows.ref('a')(row)
|
||||
rows.takeAnchor(page, ['a'], [0, 1], true)
|
||||
reader(run, 100)
|
||||
reader(detail, 35)
|
||||
act(() => vi.advanceTimersByTime(160))
|
||||
roots.restoreAll()
|
||||
expect(rows.placeShift(page)).toBe(0)
|
||||
expect(page.scrollTop).toBe(0)
|
||||
expect(correctPage).not.toHaveBeenCalled()
|
||||
const clipped = run.querySelector('[data-testid="chat-member:call:c0"]')!
|
||||
expect(mobileNativeChatVisible(clipped, page)).toBe(false)
|
||||
expect(mobileNativeChatReadingParts(row)).toContain(run)
|
||||
expect(mobileNativeChatReadingParts(row)).not.toContain(clipped)
|
||||
const anchor = run.querySelector('[data-testid="chat-member:call:c3"]')!
|
||||
const offset = rect(anchor).top - rect(run).top
|
||||
draw([user, thought, a], { shift: 80 })
|
||||
expect(owners[1]).toBe(owner)
|
||||
const restored = container.querySelector('[data-chat-scroll-root]')!
|
||||
expect(
|
||||
rect(restored.querySelector('[data-testid="chat-member:call:c3"]')!).top - rect(restored).top
|
||||
).toBe(offset)
|
||||
expect(restored.querySelector('[data-chat-scroll-root]')?.scrollTop).toBe(35)
|
||||
expect(page.scrollTop).toBe(80)
|
||||
expect(correctPage).toHaveBeenCalledExactlyOnceWith(80, false)
|
||||
const top = restored.scrollTop
|
||||
draw([user, thought, a], { shown: false, shift: 80 })
|
||||
draw([user, thought, a], { shift: 80 })
|
||||
expect(container.querySelector('[data-chat-scroll-root]')?.scrollTop).toBe(top)
|
||||
const observer = { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() }
|
||||
roots.observeWith(observer)
|
||||
expect(observer.observe).toHaveBeenCalledWith(container.querySelector('[data-chat-scroll-root]'))
|
||||
const late = document.createElement('div')
|
||||
const content = document.createElement('div')
|
||||
late.append(content)
|
||||
container.append(late)
|
||||
late.setAttribute('data-short', 'yes')
|
||||
store.runScroll.set('late', { top: 37, followsEnd: false, member: 'missing', offset: 10 })
|
||||
const stop = roots.register('late', late, content, 'late', 'late', false)
|
||||
expect(late.scrollHeight).toBeLessThan(late.clientHeight)
|
||||
expect(observer.observe).toHaveBeenCalledWith(content)
|
||||
roots.capture(new Set(), false)
|
||||
expect(store.runScroll.get('late')?.top).toBe(37)
|
||||
late.removeAttribute('data-short')
|
||||
roots.restoreAll()
|
||||
expect(late.scrollTop).toBe(37)
|
||||
expect(store.runScroll.get('late')?.member).toBeUndefined()
|
||||
const removed = vi.spyOn(late, 'removeEventListener')
|
||||
const replacement = document.createElement('div')
|
||||
late.replaceChildren(replacement)
|
||||
const replaced = roots.register('late', late, replacement, 'late', 'late', false)
|
||||
observer.unobserve.mockClear()
|
||||
stop()
|
||||
expect(roots.frame('late')).toBe(late)
|
||||
expect(observer.unobserve.mock.calls.some(([node]) => node === late)).toBe(false)
|
||||
expect(observer.unobserve.mock.calls.some(([node]) => node === replacement)).toBe(false)
|
||||
late.remove()
|
||||
roots.restoreAll()
|
||||
replaced()
|
||||
expect(roots.frame('late')).toBeUndefined()
|
||||
expect(observer.unobserve.mock.calls.some(([node]) => node === content)).toBe(true)
|
||||
expect(removed).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'issues fresh displayed owners through split and merge instead of reviving retired choices (structured=%s)',
|
||||
(structured) => {
|
||||
const draw = mount(structured)
|
||||
draw([user, a, b], { working: true })
|
||||
const original = owners[1]
|
||||
const oldKey = `run:${original}:true`
|
||||
act(() => store.write(oldKey, false))
|
||||
store.runScroll.set(oldKey, { top: 111, followsEnd: false })
|
||||
const splitRows = [
|
||||
user,
|
||||
a,
|
||||
msg('break', [{ type: 'text', text: 'answer' }], structured ? 'assistant' : 'user'),
|
||||
b
|
||||
]
|
||||
const registry = roots
|
||||
draw(splitRows, { suspend: true, working: true })
|
||||
expect(projected[1]).not.toBe(original)
|
||||
expect(owners[1]).toBe(original)
|
||||
expect(roots).toBe(registry)
|
||||
expect(store.runScroll.get(oldKey)?.top).toBe(111)
|
||||
const tentative = [...projected]
|
||||
draw(splitRows, { working: true })
|
||||
const split = [owners[1], owners[3]]
|
||||
expect(new Set([original, ...split]).size).toBe(3)
|
||||
expect(owners).toEqual(tentative)
|
||||
const reused = roots.frame(`run:${split[0]}`)!
|
||||
reader(reused, 40)
|
||||
draw([user, a, b])
|
||||
expect([original, ...split]).not.toContain(owners[1])
|
||||
expect(store.read(oldKey)).toBe(false)
|
||||
expect(store.runScroll.get(oldKey)?.top).toBe(111)
|
||||
expect(roots.frame(`run:${owners[1]}`)).toBe(reused)
|
||||
expect(reused.scrollTop).toBe(40)
|
||||
expect(store.runScroll.get(`run:${owners[1]}:true`)?.followsEnd).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for simultaneous inner rest, releases on close/scope change, and separates work-follow from reader chaining', async () => {
|
||||
const draw = mount()
|
||||
draw([user, a])
|
||||
const run = container.querySelector('[data-chat-scroll-root]')!
|
||||
const detail = run.querySelector('[data-chat-scroll-root]')!
|
||||
vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Chrome' })
|
||||
const pageMotion = new MobileNativeChatPageMotion(() => {})
|
||||
const detach = pageMotion.attach(page)
|
||||
pageMotion.reconciled(100, 1000)
|
||||
run.dispatchEvent(new Event('touchstart'))
|
||||
run.scrollTop = 100
|
||||
run.dispatchEvent(new Event('scroll'))
|
||||
let settled = false
|
||||
const waiting = roots.settled(pageMotion).then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(pageMotion.settled()).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
detail.dispatchEvent(new Event('touchstart'))
|
||||
detail.scrollTop = 35
|
||||
detail.dispatchEvent(new Event('scroll'))
|
||||
run.dispatchEvent(new Event('touchend'))
|
||||
await vi.advanceTimersByTimeAsync(160)
|
||||
expect(settled).toBe(false)
|
||||
detail.dispatchEvent(new Event('touchend'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await waiting
|
||||
reader(run, run.scrollHeight - run.clientHeight - 20)
|
||||
await vi.advanceTimersByTimeAsync(160)
|
||||
const parked = run.scrollTop
|
||||
run.setAttribute('data-growth', '60')
|
||||
roots.restoreAll()
|
||||
expect(run.scrollTop).toBe(parked)
|
||||
draw([user, a, b])
|
||||
expect(run.scrollTop).toBe(run.scrollHeight - run.clientHeight)
|
||||
run.dispatchEvent(new Event('scroll'))
|
||||
await roots.settled(pageMotion)
|
||||
expect(pageMotion.scrolled(100, 1000, () => false).by).toBe('none')
|
||||
run.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, bubbles: true }))
|
||||
expect(pageMotion.scrolled(90, 1000, () => false).by).toBe('reader')
|
||||
pageMotion.release()
|
||||
run.dispatchEvent(new Event('touchstart'))
|
||||
const closing = roots.settled(pageMotion)
|
||||
act(() => container.querySelector<HTMLElement>('[data-live]')!.click())
|
||||
await closing
|
||||
expect(run.hasAttribute('data-chat-scroll-root')).toBe(false)
|
||||
draw([user, a], { scope: 'intermediate' })
|
||||
container.querySelector('[data-chat-scroll-root]')!.dispatchEvent(new Event('touchstart'))
|
||||
const leaving = roots.settled(pageMotion)
|
||||
draw([user, a], { scope: 'other' })
|
||||
await leaving
|
||||
vi.stubGlobal('navigator', { userAgent: 'AppleWebKit Safari' })
|
||||
draw([user, a], { scope: 'other', tools: false, working: true })
|
||||
act(() => container.querySelector<HTMLElement>('[data-live]')!.click())
|
||||
const key = `run:${owners[1]}`
|
||||
const live = roots.frame(key)!
|
||||
const end = live.scrollTop
|
||||
expect(end).toBe(live.scrollHeight - live.clientHeight)
|
||||
const previous = store.runScroll.get(`${key}:false`)!
|
||||
draw([user, a], { scope: 'other', working: true, shift: 40 })
|
||||
expect(roots.frame(key)).toBe(live)
|
||||
expect(live.scrollTop).toBe(end)
|
||||
expect(live.scrollTop).toBeLessThan(live.scrollHeight - live.clientHeight)
|
||||
expect(correctPage).toHaveBeenCalledExactlyOnceWith(40, true)
|
||||
reader(live, end - 100)
|
||||
draw([user, a], { scope: 'other', tools: false, working: true, shift: 40 })
|
||||
expect(live.scrollTop).toBe(end - 100)
|
||||
expect(store.runScroll.get(`${key}:false`)?.followsEnd).toBe(false)
|
||||
expect(previous.followsEnd).toBe(true)
|
||||
reader(live, live.scrollHeight - live.clientHeight - 20)
|
||||
await vi.advanceTimersByTimeAsync(160)
|
||||
draw([user, a, b], { scope: 'other', tools: false, working: true, shift: 40 })
|
||||
expect(live.scrollTop).toBe(live.scrollHeight - live.clientHeight)
|
||||
draw([user, a, b], { scope: 'other', tools: false, working: true, shown: false, shift: 40 })
|
||||
store.runScroll.set(`${key}:false`, { top: 37, followsEnd: false })
|
||||
draw([user, a, b], { scope: 'other', tools: false, working: true, shift: 40 })
|
||||
expect(roots.frame(key)?.scrollTop).toBe(37)
|
||||
detach()
|
||||
pageMotion.release()
|
||||
})
|
||||
|
||||
it('keeps the actual changed task checklist through eviction and Tools, isolated by chat', () => {
|
||||
const draw = mount()
|
||||
const tasks = msg(
|
||||
'tasks',
|
||||
[false, true].map((done) => ({
|
||||
type: 'tool-call',
|
||||
callId: String(done),
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Remember', status: done ? 'completed' : 'pending', activeForm: 'Remembering' }
|
||||
]
|
||||
}
|
||||
}))
|
||||
)
|
||||
draw([user, tasks])
|
||||
const count = () => container.textContent?.match(/Remember/g)?.length ?? 0
|
||||
const before = count()
|
||||
const disclosure = Array.from(container.querySelectorAll('div')).findLast(
|
||||
(node) => node.textContent === 'Full task list'
|
||||
)
|
||||
if (!disclosure) {
|
||||
throw new Error('missing changed-task disclosure')
|
||||
}
|
||||
act(() => disclosure.click())
|
||||
expect(count()).toBe(before + 1)
|
||||
draw([user, tasks], { shown: false })
|
||||
draw([user, tasks])
|
||||
expect(count()).toBe(before + 1)
|
||||
draw([user, tasks], { tools: false })
|
||||
draw([user, tasks])
|
||||
expect(count()).toBe(before + 1)
|
||||
draw([user, tasks], { scope: 'different' })
|
||||
expect(count()).toBe(before)
|
||||
})
|
||||
|
||||
it("forgets a closed tool line's nested command and output positions, not its neighbours'", () => {
|
||||
const at = { top: 30, followsEnd: false }
|
||||
const memory = new Map(
|
||||
['run:o:true', 'run:o:true/line:a:command', 'run:o:true/line:a:output', 'line:a:command']
|
||||
.concat(['run:o:true/line:ab:command', 'diff:src/line:a.ts'])
|
||||
.map((address) => [address, at] as const)
|
||||
)
|
||||
new MobileNativeChatScrollRoots(memory, 10).forget('line:a')
|
||||
expect([...memory.keys()]).toEqual([
|
||||
'run:o:true',
|
||||
'run:o:true/line:ab:command',
|
||||
'diff:src/line:a.ts'
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { MobileNativeChatPageMotion } from './mobile-native-chat-page-motion'
|
||||
import type { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring'
|
||||
|
||||
export type MobileNativeChatRootPosition = {
|
||||
top: number
|
||||
followsEnd: boolean
|
||||
member?: string
|
||||
offset?: number
|
||||
}
|
||||
|
||||
type Root = {
|
||||
id: string
|
||||
address: string
|
||||
owner: string
|
||||
node: HTMLElement
|
||||
content: Element
|
||||
position: MobileNativeChatRootPosition
|
||||
work: number | undefined
|
||||
nextWork: number | undefined
|
||||
fresh: boolean
|
||||
motion: MobileNativeChatPageMotion
|
||||
anchoring: MobileNativeChatPageAnchoring
|
||||
detach: () => void
|
||||
}
|
||||
export type { Root as MobileNativeChatScrollRootHandle }
|
||||
|
||||
const owners = new WeakMap<Element, Root>()
|
||||
const ROOT = 'data-chat-scroll-root'
|
||||
export const mobileNativeChatScrollRootKey = (node: Element): string | undefined =>
|
||||
owners.get(node)?.id
|
||||
|
||||
/** The first viewport crossed from the requesting owner, never a deeper descendant's frame. */
|
||||
export function mobileNativeChatScrollFrame(node: Element, within: Element): Element | undefined {
|
||||
let frame: Element | undefined
|
||||
for (let at: Element | null = node; at && at !== within; at = at.parentElement) {
|
||||
if (owners.has(at)) {
|
||||
frame = at
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
export function mobileNativeChatVisible(node: Element, root: Element): boolean {
|
||||
const box = node.getBoundingClientRect()
|
||||
let { top, bottom } = root.getBoundingClientRect()
|
||||
for (let at = node.parentElement; at; at = at.parentElement) {
|
||||
if (
|
||||
at === root ||
|
||||
owners.has(at) ||
|
||||
/hidden|clip|auto|scroll/.test(getComputedStyle(at).overflowY)
|
||||
) {
|
||||
const clip = at.getBoundingClientRect()
|
||||
top = Math.max(top, clip.top)
|
||||
bottom = Math.min(bottom, clip.bottom)
|
||||
}
|
||||
}
|
||||
return box.bottom > top && box.top < bottom
|
||||
}
|
||||
|
||||
export function mobileNativeChatReadingParts(root: Element): Element[] {
|
||||
const parts = Array.from(root.querySelectorAll(`[data-testid^="chat-member:"], [${ROOT}]`))
|
||||
return Array.from(
|
||||
new Set(parts.map((part) => mobileNativeChatScrollFrame(part, root) ?? part))
|
||||
).filter((part) => mobileNativeChatVisible(part, root))
|
||||
}
|
||||
|
||||
export { owners as mobileNativeChatScrollRootOwners, ROOT as mobileNativeChatScrollRootAttribute }
|
||||
|
||||
export function mobileNativeChatScrollRootMember(
|
||||
root: Root,
|
||||
position = root.position
|
||||
): Element | undefined {
|
||||
return position?.member
|
||||
? Array.from(root.node.querySelectorAll('[data-testid]')).find(
|
||||
(part) =>
|
||||
part.getAttribute('data-testid') === position.member &&
|
||||
!mobileNativeChatScrollFrame(part, root.node)
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function mobileNativeChatScrollRootShift(root: Root): number {
|
||||
const position = root.position
|
||||
const member = mobileNativeChatScrollRootMember(root, position)
|
||||
return member && position?.offset !== undefined
|
||||
? member.getBoundingClientRect().top -
|
||||
root.node.getBoundingClientRect().top -
|
||||
position.offset +
|
||||
root.node.scrollTop -
|
||||
position.top
|
||||
: 0
|
||||
}
|
||||
|
||||
export function mobileNativeChatRootsDeepestFirst(roots: Iterable<Root>) {
|
||||
return Array.from(roots)
|
||||
.map((root, index) => {
|
||||
let depth = 0
|
||||
for (let node = root.node.parentElement; node; node = node.parentElement) {
|
||||
depth += 1
|
||||
}
|
||||
return { root, depth, index }
|
||||
})
|
||||
.sort((a, b) => b.depth - a.depth || a.index - b.index)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { MobileNativeChatPageMotion, pageKeepsPlace } from './mobile-native-chat-page-motion'
|
||||
import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring'
|
||||
import {
|
||||
mobileNativeChatScrollRootAttribute as ROOT,
|
||||
mobileNativeChatScrollRootOwners as owners,
|
||||
mobileNativeChatScrollRootMember as rootMember,
|
||||
mobileNativeChatScrollRootShift as shift,
|
||||
mobileNativeChatReadingParts,
|
||||
mobileNativeChatRootsDeepestFirst,
|
||||
type MobileNativeChatRootPosition,
|
||||
type MobileNativeChatScrollRootHandle as Root
|
||||
} from './mobile-native-chat-scroll-root-handle'
|
||||
export {
|
||||
mobileNativeChatScrollRootKey,
|
||||
mobileNativeChatScrollFrame,
|
||||
mobileNativeChatVisible,
|
||||
mobileNativeChatReadingParts
|
||||
} from './mobile-native-chat-scroll-root-handle'
|
||||
export type { MobileNativeChatRootPosition } from './mobile-native-chat-scroll-root-handle'
|
||||
|
||||
/** A disclosure owns its own segment anywhere in an address, its `key:part` leaves, and all below. */
|
||||
function disclosureOwns(key: string, address: string): boolean {
|
||||
for (let at = address.indexOf(key); at >= 0; at = address.indexOf(key, at + 1)) {
|
||||
const next = address[at + key.length]
|
||||
if (
|
||||
(at === 0 || address[at - 1] === '/') &&
|
||||
(next === undefined || next === '/' || next === ':')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Mounted handles only; the chat's existing bounded memory owns positions across eviction. */
|
||||
export class MobileNativeChatScrollRoots {
|
||||
private roots = new Map<string, Root>()
|
||||
private observer: ResizeObserver | null = null
|
||||
private held = new Set<Root>()
|
||||
private generation = 0
|
||||
private page: MobileNativeChatPageMotion | null = null
|
||||
|
||||
constructor(
|
||||
private readonly memory: Map<string, MobileNativeChatRootPosition>,
|
||||
private readonly limit: number
|
||||
) {}
|
||||
|
||||
register(
|
||||
id: string,
|
||||
node: HTMLElement,
|
||||
content: Element,
|
||||
owner: string,
|
||||
address: string,
|
||||
initialFollow: boolean
|
||||
): () => void {
|
||||
const was = this.roots.get(id)
|
||||
if (was?.node === node && was.content === content && was.owner === owner) {
|
||||
return () => this.unregister(was, true)
|
||||
}
|
||||
if (was) {
|
||||
this.unregister(was, true)
|
||||
}
|
||||
const occupant = owners.get(node)
|
||||
if (occupant) {
|
||||
this.unregister(occupant, true)
|
||||
}
|
||||
const motion = new MobileNativeChatPageMotion(() => {}, node.scrollTop)
|
||||
const root: Root = {
|
||||
id,
|
||||
address,
|
||||
owner,
|
||||
node,
|
||||
content,
|
||||
position: {
|
||||
...(this.memory.get(address) ?? { top: node.scrollTop, followsEnd: initialFollow })
|
||||
},
|
||||
work: undefined,
|
||||
nextWork: undefined,
|
||||
fresh: true,
|
||||
motion,
|
||||
anchoring: new MobileNativeChatPageAnchoring(
|
||||
() => node,
|
||||
() => root.position.followsEnd
|
||||
),
|
||||
detach: () => {}
|
||||
}
|
||||
const input = motion.attach(
|
||||
node,
|
||||
(event) =>
|
||||
event instanceof Event && event.target instanceof Node && node.contains(event.target)
|
||||
)
|
||||
const scrolled = () => {
|
||||
const max = node.scrollHeight - node.clientHeight
|
||||
const { by } = motion.scrolled(node.scrollTop, max, () => Math.abs(shift(root)) >= 0.5)
|
||||
if (by === 'reader') {
|
||||
if (root.nextWork !== undefined) {
|
||||
root.position.followsEnd = max - node.scrollTop <= 24
|
||||
}
|
||||
this.remember(root)
|
||||
}
|
||||
root.anchoring.sync()
|
||||
}
|
||||
node.addEventListener('scroll', scrolled, { passive: true })
|
||||
root.detach = () => {
|
||||
input()
|
||||
node.removeEventListener('scroll', scrolled)
|
||||
}
|
||||
owners.set(node, root)
|
||||
node.setAttribute(ROOT, '')
|
||||
this.roots.set(id, root)
|
||||
this.observer?.observe(node)
|
||||
this.observer?.observe(content)
|
||||
this.hold(root)
|
||||
return () => this.unregister(root, true)
|
||||
}
|
||||
|
||||
private unregister(root: Root, remember: boolean): void {
|
||||
const current = this.roots.get(root.id) === root
|
||||
if (current && remember && root.node.isConnected && !this.held.has(root)) {
|
||||
this.remember(root)
|
||||
}
|
||||
root.detach()
|
||||
root.motion.release()
|
||||
for (const target of [root.node, root.content]) {
|
||||
const viewport = owners.get(target)
|
||||
const parent = target.parentElement
|
||||
const container = parent ? owners.get(parent) : undefined
|
||||
if (
|
||||
!(viewport && viewport !== root && this.roots.get(viewport.id) === viewport) &&
|
||||
!(
|
||||
container &&
|
||||
container !== root &&
|
||||
container.content === target &&
|
||||
this.roots.get(container.id) === container
|
||||
)
|
||||
) {
|
||||
this.observer?.unobserve(target)
|
||||
}
|
||||
}
|
||||
if (owners.get(root.node) === root) {
|
||||
owners.delete(root.node)
|
||||
root.node.removeAttribute(ROOT)
|
||||
}
|
||||
if (current) {
|
||||
this.roots.delete(root.id)
|
||||
}
|
||||
if (this.held.delete(root)) {
|
||||
root.anchoring.hold(false)
|
||||
}
|
||||
}
|
||||
|
||||
readonly observeWith = (observer: ResizeObserver | null): void => {
|
||||
for (const { node, content } of this.roots.values()) {
|
||||
this.observer?.unobserve(node)
|
||||
this.observer?.unobserve(content)
|
||||
observer?.observe(node)
|
||||
observer?.observe(content)
|
||||
}
|
||||
this.observer = observer
|
||||
}
|
||||
|
||||
layout(id: string, address: string, work?: number): void {
|
||||
const root = this.roots.get(id)
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
root.address = address
|
||||
root.nextWork = work
|
||||
root.motion.laidOut(`${work}:${root.node.scrollHeight}:${root.node.clientHeight}`)
|
||||
}
|
||||
|
||||
private remember(root: Root): void {
|
||||
const { node, address } = root
|
||||
const member =
|
||||
mobileNativeChatReadingParts(node).find((part) => !owners.has(part)) ?? rootMember(root)
|
||||
root.position = {
|
||||
top: node.scrollTop,
|
||||
followsEnd: root.position.followsEnd,
|
||||
...(member
|
||||
? {
|
||||
member: member.getAttribute('data-testid') ?? undefined,
|
||||
offset: member.getBoundingClientRect().top - node.getBoundingClientRect().top
|
||||
}
|
||||
: {})
|
||||
}
|
||||
this.memory.delete(address)
|
||||
this.memory.set(address, { ...root.position })
|
||||
if (this.memory.size > this.limit) {
|
||||
const oldest = this.memory.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
this.memory.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hold(root: Root): void {
|
||||
if (!this.held.has(root)) {
|
||||
this.held.add(root)
|
||||
root.anchoring.hold(true)
|
||||
}
|
||||
}
|
||||
|
||||
capture(movedOwners: ReadonlySet<string>, regrouped: boolean): void {
|
||||
for (const root of this.roots.values()) {
|
||||
if (!root.fresh) {
|
||||
this.remember(root)
|
||||
}
|
||||
if (regrouped || movedOwners.has(root.owner)) {
|
||||
this.hold(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the parent commit boundary and the one shared observer, deepest viewport first. */
|
||||
restoreAll(regrouped = false, release = true): void {
|
||||
const roots = mobileNativeChatRootsDeepestFirst(this.roots.values())
|
||||
try {
|
||||
for (const { root } of roots) {
|
||||
const { node, motion } = root
|
||||
if (!node.isConnected) {
|
||||
continue
|
||||
}
|
||||
const max = Math.max(0, node.scrollHeight - node.clientHeight)
|
||||
const { by } = motion.reconciled(node.scrollTop, max)
|
||||
if (by === 'reader') {
|
||||
if (root.work !== undefined) {
|
||||
root.position.followsEnd = max - node.scrollTop <= 24
|
||||
}
|
||||
this.remember(root)
|
||||
}
|
||||
const position = root.position
|
||||
const pin = position.followsEnd && (root.fresh || root.work !== root.nextWork)
|
||||
const explicit = this.held.has(root)
|
||||
const member = rootMember(root, position)
|
||||
const target = pin
|
||||
? max
|
||||
: member && position?.offset !== undefined
|
||||
? node.scrollTop +
|
||||
member.getBoundingClientRect().top -
|
||||
node.getBoundingClientRect().top -
|
||||
position.offset
|
||||
: (position?.top ?? node.scrollTop)
|
||||
const ordinary = !position.followsEnd && pageKeepsPlace(node.scrollTop)
|
||||
const clamped = Math.max(0, Math.min(target, max))
|
||||
if (
|
||||
(pin || explicit || ordinary) &&
|
||||
(regrouped || motion.mayCorrect()) &&
|
||||
node.scrollTop >= 0 &&
|
||||
node.scrollTop <= max + 1 &&
|
||||
Math.abs(clamped - node.scrollTop) >= 0.5
|
||||
) {
|
||||
node.scrollTop = clamped
|
||||
motion.wrote(node.scrollTop)
|
||||
}
|
||||
root.fresh = false
|
||||
root.work = root.nextWork
|
||||
this.remember(root)
|
||||
root.anchoring.committed()
|
||||
}
|
||||
} finally {
|
||||
if (release) {
|
||||
this.releaseHold()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
releaseHold(): void {
|
||||
for (const root of this.held) {
|
||||
root.anchoring.hold(false)
|
||||
}
|
||||
this.held.clear()
|
||||
}
|
||||
|
||||
frame(key: string): Element | undefined {
|
||||
return this.roots.get(key)?.node
|
||||
}
|
||||
owner(key: string): string | undefined {
|
||||
return this.roots.get(key)?.owner
|
||||
}
|
||||
|
||||
forget(key: string): void {
|
||||
for (const root of this.roots.values()) {
|
||||
if (disclosureOwns(key, root.address)) {
|
||||
this.unregister(root, false)
|
||||
}
|
||||
}
|
||||
for (const child of this.memory.keys()) {
|
||||
if (disclosureOwns(key, child)) {
|
||||
this.memory.delete(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async settled(page: MobileNativeChatPageMotion): Promise<void> {
|
||||
const generation = this.generation
|
||||
this.page = page
|
||||
do {
|
||||
await Promise.all([
|
||||
page.whenSettled(),
|
||||
...Array.from(this.roots.values(), (r) => r.motion.whenSettled())
|
||||
])
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
} while (!page.settled() || Array.from(this.roots.values()).some((r) => !r.motion.settled()))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.generation += 1
|
||||
for (const root of this.roots.values()) {
|
||||
this.unregister(root, false)
|
||||
}
|
||||
this.page?.release()
|
||||
this.page = null
|
||||
this.observer = null
|
||||
this.releaseHold()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatToolCallBlock
|
||||
} from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Desktop parity: `NativeChatToolRunAsides`. Rows drawn among an open run's lines, each keyed:
|
||||
* before the block they are keyed by, or after the last. */
|
||||
export type MobileNativeChatToolRunAsides = {
|
||||
before: ReadonlyMap<NativeChatBlock, readonly React.JSX.Element[]>
|
||||
after: readonly React.JSX.Element[]
|
||||
}
|
||||
|
||||
export type MobileNativeChatToolRunProps = {
|
||||
blocks: NativeChatBlock[]
|
||||
/** The last plan each task-list tool wrote before this run, so an update reads as its changes. */
|
||||
previousTodoWrite?: NativeChatToolCallBlock
|
||||
previousUpdatePlan?: NativeChatToolCallBlock
|
||||
defaultExpanded: boolean
|
||||
/** The row this run belongs to. The list recycles rows, so what the reader opened is
|
||||
* remembered somewhere that outlives the row. */
|
||||
disclosureId?: string
|
||||
/** Rows drawn among the run's lines, each before the block it is keyed by, or after the last. */
|
||||
asides?: MobileNativeChatToolRunAsides
|
||||
/** Every line's default: open under the Tools toggle. */
|
||||
expandChildren: boolean
|
||||
/** Structured lifecycle state; keeps an orphaned running call from spinning. */
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Whether this run is the working turn's last. Only that run is live. */
|
||||
trailing?: boolean
|
||||
/** The turn is blocked on the reader's answer, so its calls decide liveness. */
|
||||
awaitingAnswer?: boolean
|
||||
fontScale?: number
|
||||
onOpenFile?: (path: string) => void
|
||||
}
|
||||
@@ -98,5 +98,11 @@ export const styles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
loadEarlierFailedText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xs
|
||||
}
|
||||
})
|
||||
|
||||
@@ -143,9 +143,9 @@ export const nativeChatFileInventoryRead = bindDeferredRpcOperation(
|
||||
/**
|
||||
* The older-history page native chat asks for when the transcript is scrolled back.
|
||||
*
|
||||
* A skip rather than a throw: a refused page leaves the window the subscription already delivered
|
||||
* and the scroll simply does not grow, which is what the call site's `if (!response.ok) return`
|
||||
* did. There is no screen to raise a host message on — the pane is already showing history.
|
||||
* A skip rather than a throw: a refused page leaves the window the subscription already delivered,
|
||||
* and the call site shows its own retry line. The host's message isn't raised; it is an internal
|
||||
* code the reader can't act on.
|
||||
*
|
||||
* The payload stays whole rather than being narrowed to `messages`, because the reply is a union:
|
||||
* an older runtime answers `{ error }` in place of a window, and the caller discriminates on that
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createElement, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useMobileNativeChatPageHistory } from './use-mobile-native-chat-page-history'
|
||||
|
||||
const platform = vi.hoisted(() => ({ OS: 'web' }))
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: platform }))
|
||||
|
||||
type Item = { id: string }
|
||||
type History = ReturnType<typeof useMobileNativeChatPageHistory<Item>>
|
||||
|
||||
const rows = (...ids: string[]): Item[] => ids.map((id) => ({ id }))
|
||||
const renderRow = vi.fn(({ item, index }: { item: Item; index: number }): ReactElement =>
|
||||
createElement('Row', { id: item.id, index })
|
||||
)
|
||||
|
||||
describe('useMobileNativeChatPageHistory', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let history: History | null = null
|
||||
|
||||
function Harness({ items }: { items: Item[] }): null {
|
||||
history = useMobileNativeChatPageHistory(items, renderRow, 'chat')
|
||||
return null
|
||||
}
|
||||
|
||||
async function show(items: Item[]): Promise<void> {
|
||||
await act(async () => {
|
||||
if (renderer) {
|
||||
renderer.update(createElement(Harness, { items }))
|
||||
} else {
|
||||
renderer = create(createElement(Harness, { items }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** What the list header draws below the control it was given. */
|
||||
function headerRows(): { id: string; index: number }[] {
|
||||
const drawn: { header?: ReactTestRenderer } = {}
|
||||
act(() => {
|
||||
drawn.header = create(
|
||||
createElement('Header', null, history?.withHistory(createElement('Control')))
|
||||
)
|
||||
})
|
||||
const found = drawn.header?.root.findAll((node) => typeof node.props.index === 'number') ?? []
|
||||
return found.map((row) => ({ id: row.props.id, index: row.props.index }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
history = null
|
||||
platform.OS = 'web'
|
||||
})
|
||||
|
||||
it('draws history that loads above the list in its header, leaving the list its rows', async () => {
|
||||
await show(rows('a', 'b'))
|
||||
expect(history?.listRows).toEqual(rows('a', 'b'))
|
||||
expect(headerRows()).toEqual([])
|
||||
|
||||
await show(rows('x', 'y', 'a', 'b'))
|
||||
|
||||
// RN Web's list draws by index: handing it the new rows would redraw a different set.
|
||||
expect(history?.listRows).toEqual(rows('a', 'b'))
|
||||
expect(headerRows()).toEqual([
|
||||
{ id: 'x', index: 0 },
|
||||
{ id: 'y', index: 1 }
|
||||
])
|
||||
// The list's first row is the third row of the chat, and is drawn as that.
|
||||
history?.renderListRow({ item: { id: 'a' }, index: 0 })
|
||||
expect(renderRow).toHaveBeenLastCalledWith({ item: { id: 'a' }, index: 2 })
|
||||
})
|
||||
|
||||
it('gives the list every row again once the row it started at is gone', async () => {
|
||||
await show(rows('a', 'b'))
|
||||
await show(rows('x', 'a', 'b'))
|
||||
|
||||
await show(rows('p', 'q'))
|
||||
|
||||
expect(history?.listRows).toEqual(rows('p', 'q'))
|
||||
expect(headerRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a native list all its rows: it holds its own place', async () => {
|
||||
platform.OS = 'ios'
|
||||
await show(rows('a', 'b'))
|
||||
const all = rows('x', 'y', 'a', 'b')
|
||||
|
||||
await show(all)
|
||||
|
||||
expect(history?.listRows).toBe(all)
|
||||
expect(history?.renderListRow).toBe(renderRow)
|
||||
expect(headerRows()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Fragment, useCallback, useMemo, useState, type ReactElement } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
const NO_ROWS: readonly never[] = []
|
||||
|
||||
type Row<TItem> = { item: TItem; index: number }
|
||||
|
||||
/** RN Web's list draws rows by index, so rows added at the front push the ones a reader is on out
|
||||
* of what it draws. On the page, rows that arrive above the list's first row go in its header
|
||||
* instead: the list's own rows keep their indexes and stay drawn. */
|
||||
export function useMobileNativeChatPageHistory<TItem extends { id: string }>(
|
||||
rows: readonly TItem[],
|
||||
renderRow: (row: Row<TItem>) => ReactElement,
|
||||
/** The chat shown: synthetic row ids (the streaming bubble, an echo) repeat across chats. */
|
||||
scopeKey: string
|
||||
): {
|
||||
listRows: readonly TItem[]
|
||||
renderListRow: (row: Row<TItem>) => ReactElement
|
||||
/** The list header: what was given, then the history loaded above the list's own rows. */
|
||||
withHistory: (header: ReactElement | null) => ReactElement | null
|
||||
} {
|
||||
const onPage = Platform.OS === 'web'
|
||||
// The row the list starts at, for as long as that row exists in this chat.
|
||||
const [head, setHead] = useState<{ scopeKey: string; id: string } | null>(null)
|
||||
const headId = head?.scopeKey === scopeKey ? head.id : null
|
||||
const headIndex = useMemo(
|
||||
() => (onPage && headId !== null ? rows.findIndex((row) => row.id === headId) : -1),
|
||||
[onPage, headId, rows]
|
||||
)
|
||||
const firstId = rows[0]?.id
|
||||
if (onPage && headIndex === -1 && firstId !== undefined && firstId !== headId) {
|
||||
setHead({ scopeKey, id: firstId })
|
||||
}
|
||||
const historyCount = Math.max(headIndex, 0)
|
||||
const historyRows = useMemo(
|
||||
() => (historyCount === 0 ? NO_ROWS : rows.slice(0, historyCount)),
|
||||
[historyCount, rows]
|
||||
)
|
||||
const listRows = useMemo(
|
||||
() => (historyCount === 0 ? rows : rows.slice(historyCount)),
|
||||
[historyCount, rows]
|
||||
)
|
||||
const renderOffsetRow = useCallback(
|
||||
(row: Row<TItem>) => renderRow({ item: row.item, index: row.index + historyCount }),
|
||||
[renderRow, historyCount]
|
||||
)
|
||||
const withHistory = useCallback(
|
||||
(header: ReactElement | null) =>
|
||||
historyRows.length === 0 ? (
|
||||
header
|
||||
) : (
|
||||
<>
|
||||
{header}
|
||||
{historyRows.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderRow({ item, index })}</Fragment>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
[historyRows, renderRow]
|
||||
)
|
||||
return {
|
||||
listRows,
|
||||
renderListRow: historyCount === 0 ? renderRow : renderOffsetRow,
|
||||
withHistory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
/** Renders the caller again from any event source (a scroll, the observer, a timer) synchronously,
|
||||
* so a window change is in the document before the browser paints. */
|
||||
export function useMobileNativeChatPageRender(): () => void {
|
||||
const store = useRef({ version: 0, notify: () => {} })
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
store.current.notify = notify
|
||||
return () => {
|
||||
store.current.notify = () => {}
|
||||
}
|
||||
}, [])
|
||||
useSyncExternalStore(subscribe, () => store.current.version)
|
||||
return useCallback(() => {
|
||||
store.current.version += 1
|
||||
store.current.notify()
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MobileNativeChatPageHeights } from './mobile-native-chat-page-heights'
|
||||
import { MobileNativeChatPageAnchoring } from './mobile-native-chat-page-anchoring'
|
||||
import {
|
||||
MobileNativeChatPageMotion,
|
||||
pageKeepsPlace,
|
||||
scrollWritesStopMomentum
|
||||
} from './mobile-native-chat-page-motion'
|
||||
import { MobileNativeChatPageRows } from './mobile-native-chat-page-rows'
|
||||
import { coverMobileNativeChatWindow } from './mobile-native-chat-page-window-coverage'
|
||||
import type {
|
||||
MobileNativeChatWindowDrawn as Drawn,
|
||||
MobileNativeChatWindowGroup as Group,
|
||||
MobileNativeChatPageWindowArgs,
|
||||
MobileNativeChatWindowScroller,
|
||||
MobileNativeChatWindowSeat as Win
|
||||
} from './mobile-native-chat-page-window-state'
|
||||
import { useMobileNativeChatPageRender } from './use-mobile-native-chat-page-render'
|
||||
|
||||
/** Rows measured in one settled moment: few enough not to drop a frame. */
|
||||
const MEASURE_ROWS = 8
|
||||
/** Between measuring rounds, so a streamed reply still gets frames while rows are measured. */
|
||||
const MEASURE_GAP_MS = 32
|
||||
/** A parked reader's unmeasured rows are measured only within this many screens above them. */
|
||||
const MEASURE_REACH = 3
|
||||
/** While following, every measured row is checked for a change at most this often. */
|
||||
const STALE_SCAN_MS = 1_000
|
||||
/** While following, measuring waits until a streaming reply pauses this long, so its updates keep
|
||||
* the frames. */
|
||||
const STREAM_PAUSE_MS = 300
|
||||
|
||||
/** The page's rows near the screen, keyed. Above the window the spacer stands only for measured rows:
|
||||
* rows never measured are left out, and are measured in place only while nothing can move the
|
||||
* reader (following, or settled), because iOS stops momentum on any scroll write. */
|
||||
export function useMobileNativeChatPageWindow(args: MobileNativeChatPageWindowArgs) {
|
||||
const { rows, rowKeys, rowState, following, isFollowing, layoutKey, scroller, roots } = args
|
||||
const render = useMobileNativeChatPageRender()
|
||||
const [heights] = useState(() => new MobileNativeChatPageHeights())
|
||||
const w = useRef<Win | null>(null)
|
||||
const drawn = useRef<Drawn | null>(null)
|
||||
const settle = useRef(false)
|
||||
const [drawnRows] = useState(() => new MobileNativeChatPageRows())
|
||||
const anchorStale = useRef(false)
|
||||
const width = useRef(0)
|
||||
const viewport = useRef(globalThis.innerHeight || 800)
|
||||
const lastScan = useRef(0)
|
||||
const streamedAt = useRef(0)
|
||||
const onSettled = useRef(() => {})
|
||||
const [motion] = useState(() => new MobileNativeChatPageMotion(() => onSettled.current()))
|
||||
const [anchoring] = useState(() => new MobileNativeChatPageAnchoring(scroller, isFollowing))
|
||||
const { keys, changed, indexOf } = useMemo(
|
||||
() => heights.sourceKeys(rows, rowKeys.keyOf, drawn.current?.keys),
|
||||
[rows, rowKeys.keyOf, heights]
|
||||
)
|
||||
const n = keys.length
|
||||
const at = (key: string, fallback: number): number =>
|
||||
keys[fallback] === key
|
||||
? fallback
|
||||
: (indexOf(rowKeys.current(key)) ?? Math.max(0, Math.min(fallback, n - 1)))
|
||||
const seatAtEnd = (): Win => {
|
||||
let first = n
|
||||
for (let filled = 0; first > 0 && filled < viewport.current; first -= 1) {
|
||||
filled += heights.px(keys[first - 1]) ?? heights.estimate()
|
||||
}
|
||||
const seat = { first: keys[first] ?? '', firstAt: first, last: null, lastAt: n - 1 }
|
||||
return { scope: rowKeys.scope, ...seat, slack: 0, measure: null }
|
||||
}
|
||||
|
||||
const kept = w.current?.scope === rowKeys.scope && w.current.first !== '' ? w.current : null
|
||||
let win = kept ?? seatAtEnd()
|
||||
let end = win.last === null ? n : at(win.last, win.lastAt) + 1
|
||||
if (following && win.last !== null) {
|
||||
win = end >= n ? { ...win, last: null } : seatAtEnd()
|
||||
end = n
|
||||
}
|
||||
const start = Math.min(at(win.first, win.firstAt), Math.max(0, end - 1))
|
||||
end = Math.min(n, Math.max(end, start + 1))
|
||||
|
||||
const d = drawn.current
|
||||
const sourceChanged = d !== null && d.sourceHead !== rowKeys.sourceHead
|
||||
let slack = following || !kept || settle.current ? 0 : win.slack
|
||||
if (d && sourceChanged && !following && kept && d.keys !== keys) {
|
||||
// A cap trim takes rows from the top; what was drawn of them stays as space until settled.
|
||||
for (let i = d.first; i < d.end && indexOf(rowKeys.current(d.keys[i]!)) === undefined; i += 1) {
|
||||
slack += heights.px(d.keys[i]) ?? 0
|
||||
}
|
||||
}
|
||||
// A source change keeps no measuring rows: the place-hold owns that commit's correction.
|
||||
let [m0, m1] = [start, start]
|
||||
const group = sourceChanged || !win.measure ? null : win.measure
|
||||
if (group) {
|
||||
const [from, to] = [at(group.from, group.fromAt), at(group.to, group.toAt) + 1]
|
||||
if (from < to && to <= start) {
|
||||
;[m0, m1] = [from, to]
|
||||
}
|
||||
}
|
||||
const { first, above, mid, bottom } = heights.spacers(keys, [m0, m1], [start, end], slack)
|
||||
const measuring = n > 0 && Math.min(m0, start) > 0 && heights.px(keys[0]) === undefined
|
||||
|
||||
const write = useCallback(
|
||||
(s: MobileNativeChatWindowScroller, to: number) => {
|
||||
const max = s.scrollHeight - s.clientHeight
|
||||
const target = Math.max(0, Math.min(to, max))
|
||||
// Above the top only while iOS bounces there. Past the end may be a clamp the browser hasn't
|
||||
// applied yet; every write here waits for rest, so it isn't a bounce.
|
||||
if (s.scrollTop >= 0 && Math.abs(target - s.scrollTop) >= 0.5) {
|
||||
motion.wrote(target)
|
||||
s.scrollTop = target
|
||||
}
|
||||
},
|
||||
[motion]
|
||||
)
|
||||
|
||||
const takeAnchor = useCallback(
|
||||
(s: MobileNativeChatWindowScroller) => {
|
||||
// Elsewhere the browser's anchoring keeps a parked place and the end pin a following one.
|
||||
if (!pageKeepsPlace(s.scrollTop)) {
|
||||
drawnRows.forgetAnchor()
|
||||
return
|
||||
}
|
||||
const at = drawn.current
|
||||
// A following reader is held by the end pin: the row's own top is enough there.
|
||||
drawnRows.takeAnchor(s, at?.keys ?? [], [at?.start ?? 0, at?.end ?? 0], !at?.following)
|
||||
},
|
||||
[drawnRows]
|
||||
)
|
||||
|
||||
const cover = useCallback(
|
||||
(s: MobileNativeChatWindowScroller) => {
|
||||
const [at, cur] = [drawn.current, w.current]
|
||||
const firstNode = at && drawnRows.node(at.keys[at.start])
|
||||
// Before the observer reports every drawn row, a guess would move the window off the reader.
|
||||
const unmeasured =
|
||||
at && at.keys.slice(at.start, at.end).some((key) => heights.px(key) === undefined)
|
||||
if (!at || !cur || !firstNode || unmeasured) {
|
||||
return
|
||||
}
|
||||
const next = coverMobileNativeChatWindow(
|
||||
{
|
||||
n: at.keys.length,
|
||||
start: at.start,
|
||||
end: at.end,
|
||||
// Drawn to the end only while it is near: a jump away is drawn where it lands, not to the end.
|
||||
following: at.following && s.scrollTop >= s.scrollHeight - 2 * s.clientHeight,
|
||||
windowTop:
|
||||
firstNode.getBoundingClientRect().top - s.getBoundingClientRect().top + s.scrollTop,
|
||||
scrollTop: s.scrollTop,
|
||||
viewport: s.clientHeight
|
||||
},
|
||||
(i) => heights.px(at.keys[i]),
|
||||
heights.estimate()
|
||||
)
|
||||
if (next) {
|
||||
const last = next.end >= at.keys.length ? null : at.keys[next.end - 1]!
|
||||
const measure = next.start === at.start ? cur.measure : null
|
||||
const moved = { first: at.keys[next.start]!, firstAt: next.start, last }
|
||||
w.current = { ...cur, ...moved, lastAt: next.end - 1, measure }
|
||||
render()
|
||||
}
|
||||
},
|
||||
[heights, render, drawnRows]
|
||||
)
|
||||
|
||||
// Owed work that can move rows above the reader, done only while settled: measure the nearest rows
|
||||
// that are unmeasured or may have changed, and give back trimmed space.
|
||||
const measureOwed = useCallback(() => {
|
||||
const [at, cur] = [drawn.current, w.current]
|
||||
if (!at || !cur || at.keys.length === 0) {
|
||||
return
|
||||
}
|
||||
const layout = `${at.layoutKey}:${width.current}`
|
||||
// A drawn group is measured before paint; choosing again first would take it down unmeasured.
|
||||
if (cur.measure && heights.px(cur.measure.to) === undefined) {
|
||||
return
|
||||
}
|
||||
const streaming = STREAM_PAUSE_MS - (Date.now() - streamedAt.current)
|
||||
if (at.following && streaming > 0) {
|
||||
motion.pokeIn(streaming)
|
||||
return
|
||||
}
|
||||
const since = Date.now() - lastScan.current
|
||||
const full = at.following && at.first === 0 && since >= STALE_SCAN_MS
|
||||
const run = heights.next(at, layout, MEASURE_REACH * viewport.current, MEASURE_ROWS, full)
|
||||
if (full) {
|
||||
lastScan.current = Date.now()
|
||||
} else if (!run && at.following && at.first === 0) {
|
||||
motion.pokeIn(STALE_SCAN_MS - since)
|
||||
}
|
||||
const measure: Group | null = run && {
|
||||
from: at.keys[run[0]]!,
|
||||
fromAt: run[0],
|
||||
to: at.keys[run[1]]!,
|
||||
toAt: run[1]
|
||||
}
|
||||
if (measure?.from !== cur.measure?.from || measure?.to !== cur.measure?.to || cur.slack > 0) {
|
||||
w.current = { ...cur, measure }
|
||||
settle.current = true
|
||||
motion.quiet(MEASURE_GAP_MS)
|
||||
render()
|
||||
}
|
||||
}, [heights, motion, render])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onSettled.current = measureOwed
|
||||
if (w.current && w.current.scope !== rowKeys.scope) {
|
||||
heights.clear()
|
||||
motion.release()
|
||||
drawnRows.forgetAnchor()
|
||||
}
|
||||
if (changed && drawn.current?.keys !== keys) {
|
||||
heights.retain(keys)
|
||||
}
|
||||
const last = end >= n ? null : keys[end - 1]!
|
||||
const m = m0 < m1 ? { from: keys[m0]!, fromAt: m0, to: keys[m1 - 1]!, toAt: m1 - 1 } : null
|
||||
const seat = { first: keys[start] ?? '', firstAt: start, last, lastAt: end - 1 }
|
||||
w.current = { ...win, ...seat, slack, measure: m }
|
||||
if (drawn.current && drawn.current.rows !== rows && following) {
|
||||
streamedAt.current = Date.now()
|
||||
}
|
||||
const fresh = { keys, rows, indexOf, rowState, start, end, first, following, layoutKey }
|
||||
drawn.current = { ...fresh, sourceHead: rowKeys.sourceHead }
|
||||
settle.current = false
|
||||
anchoring.committed()
|
||||
const s = scroller()
|
||||
if (s && anchorStale.current) {
|
||||
anchorStale.current = false
|
||||
takeAnchor(s)
|
||||
}
|
||||
motion.laidOut([n, m0, m1, start, end, above, mid, bottom, layoutKey].join(':'))
|
||||
if (n > 0) {
|
||||
motion.poke()
|
||||
}
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const s = scroller()
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
const scrolled = () => {
|
||||
// A write's or layout's event can arrive after rows above changed and before the correction:
|
||||
// taking the place or covering then would keep the moved position and draw what moved it.
|
||||
const max = s.scrollHeight - s.clientHeight
|
||||
const { by } = motion.scrolled(s.scrollTop, max, () => drawnRows.placeMoved(s))
|
||||
if (by === 'reader') {
|
||||
takeAnchor(s)
|
||||
}
|
||||
// Where the browser keeps the place, the page writes no correction to wait for.
|
||||
if (by === 'reader' || (by === 'layout' && !scrollWritesStopMomentum())) {
|
||||
cover(s)
|
||||
}
|
||||
}
|
||||
const detach = motion.attach(s)
|
||||
s.addEventListener('scroll', scrolled, { passive: true })
|
||||
return () => {
|
||||
detach()
|
||||
s.removeEventListener('scroll', scrolled)
|
||||
motion.release()
|
||||
}
|
||||
}, [scroller, motion, takeAnchor, cover, drawnRows, anchoring, roots])
|
||||
|
||||
/** The one observer's entries: rows measured, then, for a parked reader (on WebKit at rest),
|
||||
* whatever moved the row they were reading goes back (a measured row, the header, given-back
|
||||
* space). */
|
||||
const afterResize = useCallback(
|
||||
(entries: readonly ResizeObserverEntry[], pin: () => void) => {
|
||||
const [s, at] = [scroller(), drawn.current]
|
||||
if (!s || !at) {
|
||||
return pin()
|
||||
}
|
||||
viewport.current = s.clientHeight
|
||||
width.current = s.clientWidth
|
||||
const layout = `${at.layoutKey}:${width.current}`
|
||||
for (const entry of entries) {
|
||||
const key = drawnRows.keyOf(entry.target)
|
||||
const i = at.indexOf(key)
|
||||
if (key !== undefined && i !== undefined) {
|
||||
const row = at.rows[i]!
|
||||
heights.remember(key, entry.contentRect.height, row, at.rowState(i, row), layout)
|
||||
}
|
||||
}
|
||||
// As of the layout, before a move no scroll event has reported yet is counted.
|
||||
roots?.restoreAll()
|
||||
const own = !at.following && motion.mayCorrect() && pageKeepsPlace(s.scrollTop)
|
||||
const shift = own ? drawnRows.placeShift(s) : null
|
||||
const max = s.scrollHeight - s.clientHeight
|
||||
const { by, moved } = motion.reconciled(s.scrollTop, max)
|
||||
if (shift !== null) {
|
||||
// Rows above moved by `shift`: put that back, keep the reader's own move, undo a clamp.
|
||||
write(s, s.scrollTop - (by === 'layout' ? moved : 0) + shift)
|
||||
}
|
||||
// After the reader's unreported move is counted (it may end following), so the pin can't undo it.
|
||||
pin()
|
||||
// The place is where the reader is now: a correction that couldn't land, or a move while
|
||||
// they scrolled, is not owed later.
|
||||
takeAnchor(s)
|
||||
cover(s)
|
||||
motion.poke()
|
||||
},
|
||||
[scroller, heights, motion, write, cover, drawnRows, takeAnchor, roots]
|
||||
)
|
||||
|
||||
/** The place-hold's correction, when one may be written: on WebKit only at rest (mid-motion the
|
||||
* reader already saw the move, and putting it back at rest would be a second jump), except a
|
||||
* regroup, which resizes drawn rows at once and nothing redoes later. */
|
||||
const keepInPlace = useCallback(
|
||||
(drift: number, regrouped: boolean) => {
|
||||
const s = scroller()
|
||||
if (s && !drawn.current?.following && (regrouped || motion.mayCorrect())) {
|
||||
write(s, s.scrollTop + drift)
|
||||
anchorStale.current = true
|
||||
}
|
||||
},
|
||||
[scroller, motion, write]
|
||||
)
|
||||
|
||||
// Stable, so the list's handle (and with it the one observer) is made once.
|
||||
const list = useMemo(
|
||||
() => ({
|
||||
afterResize,
|
||||
keepInPlace,
|
||||
observeWith: (observer: ResizeObserver | null) => {
|
||||
drawnRows.observeWith(observer)
|
||||
roots?.observeWith(observer)
|
||||
},
|
||||
settled: () => roots?.settled(motion) ?? motion.whenSettled(),
|
||||
syncAnchoring: () => anchoring.sync(),
|
||||
holdPlace: (on: boolean) => anchoring.hold(on),
|
||||
watchMotion: (fn: typeof motion.onMotion) => {
|
||||
motion.onMotion = fn
|
||||
},
|
||||
wrote: (target: number) => motion.wrote(target)
|
||||
}),
|
||||
[afterResize, keepInPlace, drawnRows, motion, anchoring, roots]
|
||||
)
|
||||
|
||||
return {
|
||||
view: { m0, m1, start, end, above, mid, bottom, measuring },
|
||||
rowRef: drawnRows.ref,
|
||||
list
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import type { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat
|
||||
|
||||
/** Desktop parity: `NativeChatTranscriptRow`'s context. */
|
||||
export function useMobileNativeChatRowRenderer({
|
||||
rowKeyOf,
|
||||
runOwners,
|
||||
toolsExpanded,
|
||||
taskListPredecessors,
|
||||
fontScale,
|
||||
@@ -21,6 +23,9 @@ export function useMobileNativeChatRowRenderer({
|
||||
receipts,
|
||||
actionablePromptIds
|
||||
}: {
|
||||
/** The row's place on the page, kept while older messages join it. */
|
||||
rowKeyOf: (message: NativeChatMessage) => string
|
||||
runOwners: ReadonlyMap<string, string>
|
||||
toolsExpanded: boolean
|
||||
/** The last plan each task-list tool wrote before each row, by row id. */
|
||||
taskListPredecessors: ReadonlyMap<string, NativeChatTaskListPredecessors>
|
||||
@@ -41,6 +46,7 @@ export function useMobileNativeChatRowRenderer({
|
||||
({ item, index }) => (
|
||||
<MobileNativeChatMessage
|
||||
message={item}
|
||||
rowKey={rowKeyOf(item)}
|
||||
toolsExpanded={toolsExpanded}
|
||||
// The calls, not the record: a rebuilt record would defeat the row's memo.
|
||||
previousTodoWrite={taskListPredecessors.get(item.id)?.todowrite}
|
||||
@@ -53,9 +59,12 @@ export function useMobileNativeChatRowRenderer({
|
||||
receipt={receipts?.get(item.id)}
|
||||
awaiting={isActionableStructuredAgentSessionPrompt(item.id, actionablePromptIds)}
|
||||
{...turns.resolveRow(index, item)}
|
||||
runOwner={runOwners.get(item.id)}
|
||||
/>
|
||||
),
|
||||
[
|
||||
rowKeyOf,
|
||||
runOwners,
|
||||
toolsExpanded,
|
||||
taskListPredecessors,
|
||||
fontScale,
|
||||
|
||||
@@ -147,12 +147,16 @@ describe('mobile transcript tool result paging', () => {
|
||||
current().loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'remote-session',
|
||||
limit: hasCursor ? 60 : 100,
|
||||
...(hasCursor ? { beforeOffset: 700 } : {})
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'nativeChat.readSession',
|
||||
{
|
||||
agent: 'claude',
|
||||
sessionId: 'remote-session',
|
||||
limit: hasCursor ? 60 : 100,
|
||||
...(hasCursor ? { beforeOffset: 700 } : {})
|
||||
},
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
expect(current().messages.map((row) => row.id)).toEqual([
|
||||
'prompt',
|
||||
'older-calls',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createFakeRpcClient, rpcSuccess } from '../mobile-web-shell/bridge-host-test-fakes'
|
||||
import {
|
||||
useMobileNativeChatSession,
|
||||
type MobileNativeChatSession
|
||||
@@ -49,6 +50,36 @@ describe('useMobileNativeChatSession', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'preserves only post-response live changes while history waits (overlap: %s)',
|
||||
async (overlap) => {
|
||||
const cached = message('cached')
|
||||
const partial = message('reply')
|
||||
const history = { ...cached, blocks: [{ type: 'text' as const, text: 'fresh history' }] }
|
||||
const final = { ...partial, blocks: [{ type: 'text' as const, text: 'complete' }] }
|
||||
const response = [message('older'), history, partial, ...(overlap ? [] : [message('absent')])]
|
||||
const subscribe: RpcClient['subscribe'] = (_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({ type: 'snapshot', messages: [cached, partial], hasMore: true })
|
||||
return () => {}
|
||||
}
|
||||
const sendRequest = async () => rpcSuccess('older', { messages: response })
|
||||
let land = () => {}
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
land = resolve
|
||||
})
|
||||
await mount({ ...createFakeRpcClient(), subscribe, sendRequest })
|
||||
await act(async () => {
|
||||
state?.loadEarlier(() => settled)
|
||||
})
|
||||
await act(async () => emit({ type: 'appended', messages: [final, message('suffix')] }))
|
||||
await act(async () => land())
|
||||
expect(state?.messages.find((m) => m.id === 'reply')).toEqual(final)
|
||||
expect(state?.messages.find((m) => m.id === 'cached')).toEqual(history)
|
||||
expect(state?.messages.map((m) => m.id)).toEqual(response.map((m) => m.id).concat('suffix'))
|
||||
}
|
||||
)
|
||||
|
||||
it('drops an older-page response captured before transcript replacement', async () => {
|
||||
let resolveEarlier: (response: unknown) => void = () => {}
|
||||
const sendRequest = vi.fn(
|
||||
@@ -157,12 +188,11 @@ describe('useMobileNativeChatSession', () => {
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1)
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
limit: 60,
|
||||
beforeOffset: 500
|
||||
})
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'nativeChat.readSession',
|
||||
{ agent: 'claude', sessionId: 'session', limit: 60, beforeOffset: 500 },
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -293,11 +323,11 @@ describe('useMobileNativeChatSession', () => {
|
||||
)
|
||||
act(() => state?.loadEarlier())
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
limit: 100
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'nativeChat.readSession',
|
||||
{ agent: 'claude', sessionId: 'session', limit: 100 },
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears hasMore when a replaced window is shorter than the initial page', async () => {
|
||||
@@ -354,6 +384,44 @@ describe('useMobileNativeChatSession', () => {
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
})
|
||||
|
||||
it('lands a page across a same-cursor replay, and flags a refused page until the next tap', async () => {
|
||||
const pages: ((response: unknown) => void)[] = []
|
||||
const sendRequest = vi.fn(() => new Promise((resolve) => pages.push(resolve)))
|
||||
const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
|
||||
const snapshot = { type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 }
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData(snapshot)
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
const answer = (response: unknown) =>
|
||||
act(async () => {
|
||||
pages.at(-1)?.(response)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// A reconnect re-subscribes and replays the same newest window while the page is out.
|
||||
act(() => state?.loadEarlier())
|
||||
await act(async () => emit(snapshot))
|
||||
await answer({
|
||||
ok: true,
|
||||
result: { messages: [message('older')], hasMore: true, beforeOffset: 40 }
|
||||
})
|
||||
expect(state?.messages.map((entry) => entry.id)).toEqual([
|
||||
'older',
|
||||
...window.map((entry) => entry.id)
|
||||
])
|
||||
|
||||
act(() => state?.loadEarlier())
|
||||
await answer({ id: 'x', ok: false, error: { code: 'refused', message: 'refused' } })
|
||||
expect(state?.olderPages).toBe('failed')
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
act(() => state?.loadEarlier())
|
||||
expect(sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(state?.olderPages).toBe('loading')
|
||||
})
|
||||
|
||||
it('keeps the base snapshot authoritative when a live append arrives first', async () => {
|
||||
const sendRequest = vi.fn()
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
@@ -432,11 +500,11 @@ describe('useMobileNativeChatSession', () => {
|
||||
act(() => state?.loadEarlier())
|
||||
|
||||
// 70 rows held plus one page: a read of 100 would come back shorter than the list it replaces.
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
limit: 130
|
||||
})
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'nativeChat.readSession',
|
||||
{ agent: 'claude', sessionId: 'session', limit: 130 },
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../src/shared/native-chat-transcript-retention'
|
||||
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
|
||||
import {
|
||||
createNativeChatMerger,
|
||||
mergeNativeChatMessages,
|
||||
replaceList
|
||||
} from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
type MobileNativeChatStreamFrame
|
||||
} from './mobile-native-chat-stream-frame'
|
||||
import { randomV4Uuid } from '../platform/random-v4-uuid'
|
||||
import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader'
|
||||
|
||||
export type MobileNativeChatStatus =
|
||||
| 'idle'
|
||||
@@ -40,10 +45,13 @@ export type MobileNativeChatSession = {
|
||||
readFailedFinally?: boolean
|
||||
/** True when an older page may exist (the last read filled the window). */
|
||||
hasMore: boolean
|
||||
/** Whether an older-history page is currently loading. */
|
||||
/** `olderPages === 'loading'`, kept because recorded RPC fixtures read it. */
|
||||
loadingEarlier: boolean
|
||||
/** The Load earlier control's state: reading now, or how the last read ended. */
|
||||
olderPages?: OlderPagePhase
|
||||
/** Grow the window to page in older history. */
|
||||
loadEarlier: () => void
|
||||
/** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */
|
||||
loadEarlier: (settled?: () => Promise<void>) => void
|
||||
}
|
||||
|
||||
// Small first page for a fast first paint; grows by a page as the user scrolls.
|
||||
@@ -51,6 +59,17 @@ const INITIAL_LIMIT = 40
|
||||
const PAGE = 60
|
||||
const MAX_MESSAGES = 2000
|
||||
|
||||
/** The next older page, or null once the window holds the most it may. */
|
||||
function nextPageWindow(
|
||||
limit: number,
|
||||
listLength: number
|
||||
): { nextLimit: number; pageLimit: number } | null {
|
||||
// Live output grows the list past the last read, so the next page counts from what is held.
|
||||
const held = Math.max(limit, listLength)
|
||||
const nextLimit = Math.min(held + PAGE, MAX_MESSAGES)
|
||||
return nextLimit > held ? { nextLimit, pageLimit: nextLimit - held } : null
|
||||
}
|
||||
|
||||
type ReadSessionResult =
|
||||
| { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number }
|
||||
| { error: string }
|
||||
@@ -102,8 +121,7 @@ export function useMobileNativeChatSession(args: {
|
||||
const status = settled ? settled.status : initialStatus
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const loadingEarlierRef = useRef(false)
|
||||
const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader()
|
||||
const beforeOffsetRef = useRef<number | null>(null)
|
||||
// Stateful id-dedup merger: caches the id→index map so each live append frame
|
||||
// costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the
|
||||
@@ -113,7 +131,6 @@ export function useMobileNativeChatSession(args: {
|
||||
// Tracks the live session so a late loadEarlier resolve can detect a swap.
|
||||
const sessionIdRef = useRef<string | null>(sessionId)
|
||||
sessionIdRef.current = sessionId
|
||||
const streamGenerationRef = useRef(0)
|
||||
// Whether this subscription already delivered its base snapshot; later
|
||||
// snapshots on the same subscription are reconnect replays, not fresh bases.
|
||||
const snapshotSeenRef = useRef(false)
|
||||
@@ -136,11 +153,9 @@ export function useMobileNativeChatSession(args: {
|
||||
let cancelled = false
|
||||
// Why: disconnect/agent/session loss must invalidate a page request before
|
||||
// the early idle/waiting return can clear the visible source.
|
||||
streamGenerationRef.current += 1
|
||||
supersede()
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
loadingEarlierRef.current = false
|
||||
snapshotSeenRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
setList([])
|
||||
setError(undefined)
|
||||
setHasMore(false)
|
||||
@@ -188,12 +203,14 @@ export function useMobileNativeChatSession(args: {
|
||||
// follows is still this subscription's base, not a reconnect replay.
|
||||
snapshotSeenRef.current = true
|
||||
}
|
||||
if (applied.windowReplaced || frame.type === 'snapshot') {
|
||||
// Why: any authoritative window (and any replay merge) invalidates an
|
||||
// in-flight older-page request; stale results must not land on it.
|
||||
streamGenerationRef.current += 1
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
if (
|
||||
applied.windowReplaced ||
|
||||
(frame.type === 'snapshot' && beforeOffsetRef.current === null)
|
||||
) {
|
||||
// Why: a replaced window fences an in-flight older page, and any replay fences a
|
||||
// cursorless page, which replaces the list. A cursor page survives a contiguous
|
||||
// replay; one that re-cut the cursor fails the page's own cursor check.
|
||||
supersede()
|
||||
}
|
||||
if (applied.windowReplaced) {
|
||||
// Only a genuinely fresh window resets the grown read window — an
|
||||
@@ -212,9 +229,7 @@ export function useMobileNativeChatSession(args: {
|
||||
if (applied.cursorInvalidated) {
|
||||
// Fall back to a growing-tail read so history trimmed by live appends
|
||||
// cannot leave a gap between the retained window and the old cursor.
|
||||
streamGenerationRef.current += 1
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
supersede()
|
||||
beforeOffsetRef.current = null
|
||||
}
|
||||
setRead({ client, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' })
|
||||
@@ -223,89 +238,92 @@ export function useMobileNativeChatSession(args: {
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
supersede()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, agent, sessionId, transcriptPath, identity, setList])
|
||||
}, [client, agent, sessionId, transcriptPath, identity, setList, supersede])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
|
||||
return
|
||||
}
|
||||
// Capture the session this page belongs to; a swap underneath us must not
|
||||
// apply this read's result onto the new session (mirrors desktop's guard).
|
||||
const requestSessionId = sessionId
|
||||
const requestGeneration = streamGenerationRef.current
|
||||
// Live output grows the list past the last read, so the next page counts from what is held.
|
||||
const held = Math.max(limitRef.current, mergerRef.current.list.length)
|
||||
const nextLimit = Math.min(held + PAGE, MAX_MESSAGES)
|
||||
const pageLimit = nextLimit - held
|
||||
if (pageLimit <= 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
const beforeOffset = beforeOffsetRef.current
|
||||
loadingEarlierRef.current = true
|
||||
setLoadingEarlier(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await nativeChatSessionPageRead.request(client, {
|
||||
agent,
|
||||
sessionId,
|
||||
limit: beforeOffset === null ? nextLimit : pageLimit,
|
||||
...(beforeOffset === null ? {} : { beforeOffset }),
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
})
|
||||
const loadEarlier = useCallback(
|
||||
(settled?: () => Promise<void>) => {
|
||||
if (!client || !agent || !sessionId || !hasMore) {
|
||||
return
|
||||
}
|
||||
// Capture the session this page belongs to; a swap underneath us must not
|
||||
// apply this read's result onto the new session (mirrors desktop's guard).
|
||||
const requestSessionId = sessionId
|
||||
loadOlderPages(async ({ timeoutMs, isCurrent }) => {
|
||||
const page = nextPageWindow(limitRef.current, mergerRef.current.list.length)
|
||||
if (!page) {
|
||||
setHasMore(false)
|
||||
return 'end'
|
||||
}
|
||||
const { nextLimit, pageLimit } = page
|
||||
const beforeOffset = beforeOffsetRef.current
|
||||
const response = await nativeChatSessionPageRead.request(
|
||||
client,
|
||||
{
|
||||
agent,
|
||||
sessionId,
|
||||
limit: beforeOffset === null ? nextLimit : pageLimit,
|
||||
...(beforeOffset === null ? {} : { beforeOffset }),
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
},
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
const accepted = nativeChatSessionPageRead.interpret(response)
|
||||
if (!accepted.accepted) {
|
||||
return
|
||||
return 'failed'
|
||||
}
|
||||
// The read is `z.unknown()` because the reply is a union, so an accepted success can still
|
||||
// carry no result at all, or null; `'error' in` throws on either.
|
||||
const payload = accepted.value
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
return
|
||||
return 'failed'
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: main cast this payload unread; the reader hands back the same result.
|
||||
const result = payload as ReadSessionResult
|
||||
if ('error' in result) {
|
||||
return
|
||||
return 'failed'
|
||||
}
|
||||
// Drop a stale resolve from a session that swapped underneath us.
|
||||
const receivedLive = new Map(mergerRef.current.list.map((message) => [message.id, message]))
|
||||
// The page lands once the reader stops scrolling: rows growing above them mid-fling would move
|
||||
// what they read, and iOS stops the fling on the write that would hold it.
|
||||
if (settled) {
|
||||
await settled()
|
||||
}
|
||||
// Lands only on the window and cursor it was asked from; otherwise the window it
|
||||
// read against is gone, and the reader pages again from the new one.
|
||||
if (
|
||||
!isCurrent() ||
|
||||
sessionIdRef.current !== requestSessionId ||
|
||||
streamGenerationRef.current !== requestGeneration
|
||||
beforeOffsetRef.current !== beforeOffset
|
||||
) {
|
||||
return
|
||||
return 'end'
|
||||
}
|
||||
limitRef.current = nextLimit
|
||||
let more: boolean
|
||||
if (beforeOffset !== null && result.beforeOffset != null) {
|
||||
beforeOffsetRef.current = result.beforeOffset
|
||||
setList([...result.messages, ...mergerRef.current.list])
|
||||
setHasMore(
|
||||
nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit)
|
||||
)
|
||||
more = nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit)
|
||||
} else {
|
||||
// Older runtimes ignore the cursor and return the growing tail.
|
||||
setList(result.messages)
|
||||
setHasMore(result.messages.length >= nextLimit)
|
||||
// Older runtimes ignore the cursor and return the growing tail; rows that streamed in while
|
||||
// it waited stay.
|
||||
const live = mergerRef.current.list
|
||||
const after = live.findIndex((message) => message.id === result.messages.at(-1)?.id)
|
||||
const landed =
|
||||
after === -1 ? result.messages : [...result.messages, ...live.slice(after + 1)]
|
||||
// Only updates accepted after receipt outrank history; unchanged cached rows may be stale.
|
||||
const changed = live.filter((message) => receivedLive.get(message.id) !== message)
|
||||
setList(mergeNativeChatMessages(landed, changed))
|
||||
more = result.messages.length >= nextLimit
|
||||
}
|
||||
} catch {
|
||||
// Nothing awaits this page, so a rejected request — a transport drop, or the client
|
||||
// abandoning it at teardown — would otherwise reach the document as an unhandled
|
||||
// rejection. Swallowed to match the operation's own skip policy: a page that never
|
||||
// arrives leaves the window the subscription already delivered.
|
||||
} finally {
|
||||
// A late page from a prior tab must not unlock the current tab's request.
|
||||
if (
|
||||
sessionIdRef.current === requestSessionId &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
) {
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
setHasMore(more)
|
||||
return more ? 'drew' : 'end'
|
||||
})
|
||||
},
|
||||
[client, agent, sessionId, transcriptPath, hasMore, setList, loadOlderPages]
|
||||
)
|
||||
|
||||
// Held for any unsettled read, not just an in-flight one: a stream error or a
|
||||
// dropped client would otherwise trade the conversation for an error card.
|
||||
@@ -323,7 +341,8 @@ export function useMobileNativeChatSession(args: {
|
||||
transcriptLoading: status === 'loading' || status === 'awaiting-transcript',
|
||||
error,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadingEarlier: olderPages === 'loading',
|
||||
olderPages,
|
||||
loadEarlier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import {
|
||||
Platform,
|
||||
type FlatList,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent
|
||||
} from 'react-native'
|
||||
import { Platform, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'
|
||||
import type { MobileNativeChatScrollNode } from './MobileNativeChatScrollHold'
|
||||
import type { MobileNativeChatPageObservation } from './mobile-native-chat-page-motion'
|
||||
|
||||
/** Distance from the bottom, in points, still treated as "at the tail". */
|
||||
const AT_TAIL_SLOP = 80
|
||||
@@ -26,8 +23,23 @@ export function mobileNativeChatRowAnchor(hasHeader: boolean): { minIndexForVisi
|
||||
return hasHeader ? ANCHOR_BELOW_HEADER : ANCHOR_AT_FIRST_ROW
|
||||
}
|
||||
|
||||
/** The part of the page list's scrollable DOM node the pin reads. */
|
||||
type PageScroller = { scrollHeight: number }
|
||||
/** The list's scrollable node on the page, as RN Web's ScrollView decorates it. */
|
||||
type PageScroller = Element & MobileNativeChatScrollNode & { getInnerViewNode: () => Element }
|
||||
|
||||
/** RN Web reports content size a task after layout, so a frame can paint between growth and a pin
|
||||
* driven by that report. An observer on the scroller and its content runs before that paint. */
|
||||
function observePageResize(
|
||||
scroller: PageScroller | null | undefined,
|
||||
onResize: (entries: readonly ResizeObserverEntry[]) => void
|
||||
): ResizeObserver | null {
|
||||
if (!scroller || typeof ResizeObserver === 'undefined') {
|
||||
return null
|
||||
}
|
||||
const observer = new ResizeObserver(onResize)
|
||||
observer.observe(scroller)
|
||||
observer.observe(scroller.getInnerViewNode())
|
||||
return observer
|
||||
}
|
||||
|
||||
function distanceFromTail(event: NativeScrollEvent): number {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event
|
||||
@@ -38,14 +50,43 @@ function isAtTail(event: NativeScrollEvent): boolean {
|
||||
return distanceFromTail(event) <= AT_TAIL_SLOP
|
||||
}
|
||||
|
||||
export type MobileNativeChatTailFollow<TItem> = {
|
||||
/** Attach to the transcript list; the hook scrolls through this ref alone. */
|
||||
listRef: RefObject<FlatList<TItem> | null>
|
||||
/** What the owner and the place-hold use of a transcript list: native's FlatList, or the page's. */
|
||||
export type MobileNativeChatListHandle = {
|
||||
getScrollableNode: () => PageScroller | null | undefined
|
||||
scrollToOffset: (params: { animated: boolean; offset: number }) => void
|
||||
scrollToEnd: (params: { animated: boolean }) => void
|
||||
/** Page only: each resize, with the observer's entries; runs the end `pin` once it has counted a
|
||||
* move of the reader's no scroll event reported yet. */
|
||||
afterResize?: (entries: readonly ResizeObserverEntry[], pin: () => void) => void
|
||||
/** Page only: the list's rows join the one observer, so each reports its own height. */
|
||||
observeWith?: (observer: ResizeObserver | null) => void
|
||||
/** Page only: the place-hold's correction, written while the reader moves only for a regroup. */
|
||||
keepInPlace?: (drift: number, regrouped: boolean) => void
|
||||
/** Page only: resolves once the reader stops moving the list, or the list goes away. */
|
||||
settled?: () => Promise<void>
|
||||
/** Page only: each classified move, before any resize correction or end pin. */
|
||||
watchMotion?: (fn: ((observation: MobileNativeChatPageObservation) => void) | null) => void
|
||||
/** Page only: following changed, which decides whether the browser's anchoring may move it. */
|
||||
syncAnchoring?: () => void
|
||||
/** Page only: the place-hold takes the scroll from the browser's anchoring for its correction. */
|
||||
holdPlace?: (on: boolean) => void
|
||||
}
|
||||
|
||||
export type MobileNativeChatTailFollow = {
|
||||
/** Spread on the native list. `ref`: the hook scrolls through it alone. */
|
||||
listProps: { ref: (list: MobileNativeChatListHandle | null) => () => void }
|
||||
/** Spread on the page list: the same `ref`, which also watches the list resize. */
|
||||
pageListProps: {
|
||||
ref: (list: MobileNativeChatListHandle | null) => () => void
|
||||
endAnchored: boolean
|
||||
isFollowing: () => boolean
|
||||
pin: () => void
|
||||
}
|
||||
/** Render flag for the jump-to-latest control. */
|
||||
showJumpToTail: boolean
|
||||
/** Passive maintenance: re-pin after the viewport resizes. */
|
||||
pinToTail: () => void
|
||||
/** Content-size maintenance using the list's authoritative measured height. */
|
||||
/** Content-size maintenance: native uses the measured height, the page its live end. */
|
||||
pinToTailAfterContentResize: (_width: number, height: number) => void
|
||||
/** Explicit jump — send, or the jump-to-latest control. Resumes following. */
|
||||
jumpToTail: () => void
|
||||
@@ -53,9 +94,15 @@ export type MobileNativeChatTailFollow<TItem> = {
|
||||
endUserDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
|
||||
beginMomentum: () => void
|
||||
endMomentum: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
|
||||
/** Whether a scroll near the top is the reader's own, and so may page in older history. */
|
||||
readerIsScrolling: () => boolean
|
||||
/** Whether a scroll near the top may page in older history: only a native list's own drag. The
|
||||
* page loads it only from the "Load earlier" control, since a page landing in iOS's top bounce
|
||||
* gets the place-hold's write while WebKit owns the scroll, and the list stops scrolling. */
|
||||
scrollMayLoadEarlier: () => boolean
|
||||
recordScrollMetrics: (event: NativeScrollEvent) => void
|
||||
/** Page only: an older page waits for it, so it never lands under a reader who is scrolling. */
|
||||
pageSettled: (() => Promise<void>) | undefined
|
||||
/** Read at commit by the page's place-hold: the list, and whether the reader follows the end. */
|
||||
placeHold: { listRef: RefObject<MobileNativeChatListHandle | null>; isFollowing: () => boolean }
|
||||
}
|
||||
|
||||
/** Sole owner of transcript scroll position.
|
||||
@@ -69,17 +116,17 @@ export type MobileNativeChatTailFollow<TItem> = {
|
||||
* Intent (`following`) is kept separate from geometry (at-tail): a programmatic
|
||||
* scroll reports metrics like any other, so letting metrics decide intent let
|
||||
* the view argue with itself. Only the user's own gestures and explicit jumps
|
||||
* move intent; metrics only decide where a *released* gesture leaves us.
|
||||
* move native intent; metrics decide where a *released* gesture leaves us. On
|
||||
* the page, a move toward the end resumes following; only reader input detaches it.
|
||||
*/
|
||||
export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
/** The list's rows; an empty list is never scrolled. */
|
||||
items: readonly TItem[]
|
||||
/** The chat shown; another one opens following, whatever the reader did in this one. */
|
||||
export function useMobileNativeChatTailFollow(args: {
|
||||
/** Guards `scrollToEnd` against an empty list. */
|
||||
hasItems: boolean
|
||||
/** The chat shown; another one opens following, whatever the reader did here. */
|
||||
scopeKey: string
|
||||
}): MobileNativeChatTailFollow<TItem> {
|
||||
const { scopeKey } = args
|
||||
const hasItems = args.items.length > 0
|
||||
const listRef = useRef<FlatList<TItem> | null>(null)
|
||||
}): MobileNativeChatTailFollow {
|
||||
const { hasItems, scopeKey } = args
|
||||
const listRef = useRef<MobileNativeChatListHandle | null>(null)
|
||||
const [following, setFollowingFlag] = useState(true)
|
||||
const [atTail, setAtTailFlag] = useState(true)
|
||||
// Event handlers read intent at event time, before a re-render lands.
|
||||
@@ -87,9 +134,6 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
const atTailRef = useRef(true)
|
||||
const userScrollActiveRef = useRef(false)
|
||||
const userScrollSettleFrameRef = useRef<number | null>(null)
|
||||
// Page only: what the offsets last said, in place of the gestures RN Web does not report.
|
||||
const pageOffsetRef = useRef(0)
|
||||
const pageReaderMovedUpRef = useRef(false)
|
||||
const scopeKeyRef = useRef(scopeKey)
|
||||
|
||||
// Single writer, so the event-time ref and the render flag cannot disagree.
|
||||
@@ -98,6 +142,8 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
return
|
||||
}
|
||||
followingRef.current = next
|
||||
// Before any write or commit: the browser's anchoring and the end pin never both hold the place.
|
||||
listRef.current?.syncAnchoring?.()
|
||||
setFollowingFlag(next)
|
||||
}, [])
|
||||
|
||||
@@ -109,30 +155,76 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
setAtTailFlag(next)
|
||||
}, [])
|
||||
|
||||
// Live geometry: a size report arrives late and can name an end the view has already left.
|
||||
// Not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's.
|
||||
const pinPageToEnd = useCallback(() => {
|
||||
const scroller = listRef.current?.getScrollableNode()
|
||||
if (!followingRef.current || !scroller) {
|
||||
return
|
||||
}
|
||||
const end = scroller.scrollHeight - scroller.clientHeight
|
||||
// Already there, or iOS bouncing past an end: a write then lands while WebKit owns the scroll.
|
||||
if (scroller.scrollTop < 0 || scroller.scrollTop >= end - PAGE_MOVE_SLOP) {
|
||||
return
|
||||
}
|
||||
listRef.current?.scrollToOffset({ animated: false, offset: end })
|
||||
}, [])
|
||||
|
||||
const attachList = useCallback(
|
||||
(list: MobileNativeChatListHandle | null) => {
|
||||
listRef.current = list
|
||||
const page = !listReportsGestures()
|
||||
// Following comes only from classified motion: a resize can land a parked reader on a shorter
|
||||
// end (a collapse below them), and that clamp is layout's, not the reader asking to follow.
|
||||
const observer = page
|
||||
? observePageResize(list?.getScrollableNode(), (entries) =>
|
||||
list?.afterResize ? list.afterResize(entries, pinPageToEnd) : pinPageToEnd()
|
||||
)
|
||||
: null
|
||||
list?.observeWith?.(observer)
|
||||
// Only a move toward the end attaches: a clamp onto a shorter end moves up, even one that
|
||||
// lands under the reader's finger (a tap on a collapse below them).
|
||||
list?.watchMotion?.(({ by, moved, fromEnd }) => {
|
||||
if (followingRef.current) {
|
||||
// Any upward move of the reader's that leaves the end detaches: a slow drag reports many
|
||||
// 1px moves, and none of them alone is past a per-event threshold.
|
||||
if (by === 'reader' && moved < 0 && fromEnd > PAGE_MOVE_SLOP) {
|
||||
setFollowing(false)
|
||||
}
|
||||
} else if (
|
||||
(by === 'reader' && moved > 0 && fromEnd <= PAGE_MOVE_SLOP) ||
|
||||
(by !== 'own' && moved > PAGE_MOVE_SLOP && fromEnd <= AT_TAIL_SLOP)
|
||||
) {
|
||||
setFollowing(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
list?.watchMotion?.(null)
|
||||
list?.observeWith?.(null)
|
||||
observer?.disconnect()
|
||||
listRef.current = null
|
||||
}
|
||||
},
|
||||
[pinPageToEnd, setFollowing]
|
||||
)
|
||||
|
||||
const pinToTail = useCallback(() => {
|
||||
if (!followingRef.current || !hasItems) {
|
||||
return
|
||||
}
|
||||
if (listReportsGestures()) {
|
||||
if (!listReportsGestures()) {
|
||||
pinPageToEnd()
|
||||
} else if (followingRef.current && hasItems) {
|
||||
listRef.current?.scrollToEnd({ animated: false })
|
||||
return
|
||||
}
|
||||
// Why not `scrollToEnd`: its estimate can land short, and the page reads a move up as the reader's.
|
||||
// The live height: the last reported one can still be the previous chat's.
|
||||
const scroller: PageScroller | null | undefined = listRef.current?.getScrollableNode()
|
||||
if (scroller) {
|
||||
listRef.current?.scrollToOffset({ animated: false, offset: scroller.scrollHeight })
|
||||
}
|
||||
}, [hasItems])
|
||||
}, [hasItems, pinPageToEnd])
|
||||
|
||||
const pinToTailAfterContentResize = useCallback(
|
||||
(_width: number, height: number) => {
|
||||
if (!followingRef.current || !hasItems) {
|
||||
return
|
||||
if (!listReportsGestures()) {
|
||||
pinPageToEnd()
|
||||
} else if (followingRef.current && hasItems) {
|
||||
listRef.current?.scrollToOffset({ animated: false, offset: height })
|
||||
}
|
||||
listRef.current?.scrollToOffset({ animated: false, offset: height })
|
||||
},
|
||||
[hasItems]
|
||||
[hasItems, pinPageToEnd]
|
||||
)
|
||||
|
||||
const clearUserScrollSettle = useCallback(() => {
|
||||
@@ -144,24 +236,9 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
|
||||
const recordScrollMetrics = useCallback(
|
||||
(event: NativeScrollEvent) => {
|
||||
const endsAtTail = isAtTail(event)
|
||||
setAtTail(endsAtTail)
|
||||
if (listReportsGestures()) {
|
||||
return
|
||||
}
|
||||
const moved = event.contentOffset.y - pageOffsetRef.current
|
||||
pageOffsetRef.current = event.contentOffset.y
|
||||
pageReaderMovedUpRef.current = moved < -PAGE_MOVE_SLOP
|
||||
if (followingRef.current) {
|
||||
// A pin only moves toward the end and a clamp lands on it: up and short of it is the reader.
|
||||
if (pageReaderMovedUpRef.current && distanceFromTail(event) > PAGE_MOVE_SLOP) {
|
||||
setFollowing(false)
|
||||
}
|
||||
} else if (moved > PAGE_MOVE_SLOP && endsAtTail) {
|
||||
setFollowing(true)
|
||||
}
|
||||
setAtTail(isAtTail(event))
|
||||
},
|
||||
[setAtTail, setFollowing]
|
||||
[setAtTail]
|
||||
)
|
||||
|
||||
const jumpToTail = useCallback(() => {
|
||||
@@ -221,25 +298,29 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
[finishUserScroll]
|
||||
)
|
||||
|
||||
const readerIsScrolling = useCallback(
|
||||
() => (listReportsGestures() ? userScrollActiveRef.current : pageReaderMovedUpRef.current),
|
||||
const scrollMayLoadEarlier = useCallback(
|
||||
() => listReportsGestures() && userScrollActiveRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
// Before paint, so the next chat never shows where the reader left the last one.
|
||||
const isFollowing = useCallback(() => followingRef.current, [])
|
||||
const pageSettled = useCallback(() => listRef.current?.settled?.() ?? Promise.resolve(), [])
|
||||
|
||||
// The view stays mounted across chats: before paint, so the next one never shows where the reader
|
||||
// left the last.
|
||||
useLayoutEffect(() => {
|
||||
if (scopeKeyRef.current !== scopeKey) {
|
||||
scopeKeyRef.current = scopeKey
|
||||
pageOffsetRef.current = 0
|
||||
pageReaderMovedUpRef.current = false
|
||||
jumpToTail()
|
||||
if (scopeKeyRef.current === scopeKey) {
|
||||
return
|
||||
}
|
||||
scopeKeyRef.current = scopeKey
|
||||
jumpToTail()
|
||||
}, [scopeKey, jumpToTail])
|
||||
|
||||
useEffect(() => clearUserScrollSettle, [clearUserScrollSettle])
|
||||
|
||||
return {
|
||||
listRef,
|
||||
listProps: { ref: attachList },
|
||||
pageListProps: { ref: attachList, endAnchored: following, isFollowing, pin: pinPageToEnd },
|
||||
showJumpToTail: !following && !atTail,
|
||||
pinToTail,
|
||||
pinToTailAfterContentResize,
|
||||
@@ -248,7 +329,9 @@ export function useMobileNativeChatTailFollow<TItem>(args: {
|
||||
endUserDrag,
|
||||
beginMomentum,
|
||||
endMomentum,
|
||||
readerIsScrolling,
|
||||
recordScrollMetrics
|
||||
scrollMayLoadEarlier,
|
||||
recordScrollMetrics,
|
||||
pageSettled: listReportsGestures() ? undefined : pageSettled,
|
||||
placeHold: { listRef, isFollowing }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/nat
|
||||
import { NATIVE_CHAT_STREAMING_ID } from '../../../src/shared/native-chat-streaming'
|
||||
import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt'
|
||||
import { useMobileNativeChatTranscriptRows } from './use-mobile-native-chat-transcript-rows'
|
||||
import type { MobileNativeChatRunPresentation } from './mobile-native-chat-run-descriptors'
|
||||
import {
|
||||
nativeChatRowsInDrawOrder,
|
||||
nativeChatTurnBarRows
|
||||
@@ -132,6 +133,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
scopeKey: string
|
||||
}): {
|
||||
active: NativeChatTurnStatus | null
|
||||
presentation: MobileNativeChatRunPresentation
|
||||
/** The live turn's provider activity copy, for the footer row. */
|
||||
activeActivityText: string | null
|
||||
onToggleTurn: (turnKey: string) => void
|
||||
@@ -233,6 +235,10 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
expandedTurnKeys: expandedTurnIds,
|
||||
liveReasoningId
|
||||
})
|
||||
const presentation = useMemo(
|
||||
() => ({ rows, workRuns, turnKeys, receipts }),
|
||||
[rows, workRuns, turnKeys, receipts]
|
||||
)
|
||||
const latestAssistantId = useMemo(
|
||||
() => waiting.listMessages.findLast((row) => row.role === 'assistant')?.id ?? null,
|
||||
[waiting.listMessages]
|
||||
@@ -316,6 +322,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
|
||||
return {
|
||||
active,
|
||||
presentation,
|
||||
activeActivityText,
|
||||
/** Stable for a given chat scope, so it never disturbs a row's memo. */
|
||||
onToggleTurn: toggleExpandedTurn,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { RPC_REQUEST_MIN_ACK_MS } from '../transport/rpc-request-budget'
|
||||
|
||||
/** One tap's whole wait, connect included, shared by every page it reads. */
|
||||
export const OLDER_PAGE_BUDGET_MS = 15_000
|
||||
/** Bounds the pages one tap reads past pages that draw nothing. */
|
||||
export const OLDER_PAGES_PER_TAP = 8
|
||||
|
||||
/** One page of a tap: it drew a row, drew nothing (read on), reached the end, failed in a way a
|
||||
* retry can get past, or met history that can never be read from there. */
|
||||
export type OlderPageStep = 'drew' | 'drew-nothing' | 'end' | 'failed' | 'unavailable'
|
||||
|
||||
/** What the Load earlier control shows. */
|
||||
export type OlderPagePhase = 'idle' | 'loading' | 'failed' | 'unavailable'
|
||||
|
||||
/** One request of a tap. `timeoutMs` is what is left of the tap's budget, connect included. */
|
||||
export type OlderPageReader = (request: {
|
||||
timeoutMs: number
|
||||
isCurrent: () => boolean
|
||||
}) => Promise<OlderPageStep>
|
||||
|
||||
/**
|
||||
* The one owner of an older-page read for either chat lane. A read ends by its own settlement or by
|
||||
* `supersede()`, and both clear the spinner here, so no path can strand it.
|
||||
*/
|
||||
export function useMobileOlderPageLoader(): {
|
||||
phase: OlderPagePhase
|
||||
/** Starts a tap's read unless one is running. */
|
||||
load: (reader: OlderPageReader) => void
|
||||
/** The window the read was for is gone: ends it silently and clears the spinner and the line. */
|
||||
supersede: () => void
|
||||
} {
|
||||
const [phase, setPhase] = useState<OlderPagePhase>('idle')
|
||||
// Only the running read's token may settle the phase, so a superseded read's late reply can't.
|
||||
const tokenRef = useRef<object | null>(null)
|
||||
// History that can't be read stays so until the window is replaced, whatever re-triggers a read.
|
||||
const unavailableRef = useRef(false)
|
||||
|
||||
const load = useCallback((reader: OlderPageReader) => {
|
||||
if (tokenRef.current || unavailableRef.current) {
|
||||
return
|
||||
}
|
||||
const token = {}
|
||||
tokenRef.current = token
|
||||
setPhase('loading')
|
||||
const isCurrent = (): boolean => tokenRef.current === token
|
||||
const deadline = Date.now() + OLDER_PAGE_BUDGET_MS
|
||||
void (async (): Promise<OlderPageStep> => {
|
||||
let step: OlderPageStep = 'drew-nothing'
|
||||
for (let page = 0; page < OLDER_PAGES_PER_TAP && step === 'drew-nothing'; page += 1) {
|
||||
const timeoutMs = deadline - Date.now()
|
||||
// Too little left to be answered: what landed stays, and the next tap reads on.
|
||||
if (!isCurrent() || timeoutMs < RPC_REQUEST_MIN_ACK_MS) {
|
||||
return 'end'
|
||||
}
|
||||
step = await reader({ timeoutMs, isCurrent })
|
||||
}
|
||||
return step
|
||||
})()
|
||||
// Contained here: nothing awaits this read, so a transport drop would be unhandled.
|
||||
.catch((): OlderPageStep => 'failed')
|
||||
.then((step) => {
|
||||
if (isCurrent()) {
|
||||
tokenRef.current = null
|
||||
unavailableRef.current = step === 'unavailable'
|
||||
setPhase(step === 'failed' || step === 'unavailable' ? step : 'idle')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const supersede = useCallback(() => {
|
||||
tokenRef.current = null
|
||||
unavailableRef.current = false
|
||||
setPhase('idle')
|
||||
}, [])
|
||||
|
||||
return { phase, load, supersede }
|
||||
}
|
||||
@@ -17,7 +17,7 @@ vi.mock('./use-mobile-structured-agent-state', () => ({
|
||||
useMobileStructuredAgentState: () => ({
|
||||
state,
|
||||
stateRef,
|
||||
loadingOlder: false,
|
||||
olderPages: 'idle',
|
||||
loadEarlier: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useMobileStructuredAgentSession(
|
||||
// Against a host that predates the quiet repeated Stop, a Stop of a turn still being stopped joins it.
|
||||
const inFlightStopsRef = useRef(new Map<string, Promise<boolean>>())
|
||||
const stateArgs = { client, sessionId, sessionKey, enabled, connected }
|
||||
const { state, stateRef, queuedMessages, queuePause, loadingOlder, loadEarlier } =
|
||||
const { state, stateRef, queuedMessages, queuePause, olderPages, loadEarlier } =
|
||||
useMobileStructuredAgentState(stateArgs)
|
||||
|
||||
const mutate = useMobileStructuredAgentMutate({
|
||||
@@ -291,8 +291,10 @@ export function useMobileStructuredAgentSession(
|
||||
transcriptLoading: status === 'loading',
|
||||
error: state.error,
|
||||
readFailedFinally: status === 'error' && isFinalAgentSessionReadRefusal(state.readRefusal),
|
||||
hasMore: state.hasOlder,
|
||||
loadingEarlier: loadingOlder,
|
||||
// History that can never be read from here ends paging; its line says so.
|
||||
hasMore: state.hasOlder && olderPages !== 'unavailable',
|
||||
loadingEarlier: olderPages === 'loading',
|
||||
olderPages,
|
||||
loadEarlier
|
||||
},
|
||||
isWorking,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalRenderItem
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalResetReason
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionHistoryPage } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -59,9 +60,10 @@ describe('useMobileStructuredAgentState older history', () => {
|
||||
})
|
||||
|
||||
/** Mounts the hook on a session whose newest rows are `newest` (by default one of the
|
||||
* session's own, at 1000), with the host serving `older` by the cursor each read asks before. */
|
||||
* session's own, at 1000), with the host serving `older` by the cursor each read asks before:
|
||||
* a page, a reset, or a thrown error. */
|
||||
async function mountWithOlderPages(
|
||||
older: Record<number, AgentSessionHistoryPage | undefined>,
|
||||
older: Record<number, AgentSessionHistoryPage | AgentJournalResetReason | Error | undefined>,
|
||||
newest: AgentJournalRenderItem[] = [said(1_000)]
|
||||
) {
|
||||
let onFrame: ((value: unknown) => void) | null = null
|
||||
@@ -71,10 +73,16 @@ describe('useMobileStructuredAgentState older history', () => {
|
||||
return { ok: true, result: {} }
|
||||
}
|
||||
historyCursors.push(params.cursor.sequence)
|
||||
const olderPage = older[params.cursor.sequence]
|
||||
const olderPage = older[params.cursor.sequence] ?? 'cursor_ahead'
|
||||
if (olderPage instanceof Error) {
|
||||
throw olderPage
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: olderPage ? { ok: true, page: olderPage } : { ok: false, reset: 'cursor_ahead' }
|
||||
result:
|
||||
typeof olderPage === 'string'
|
||||
? { ok: false, reset: olderPage }
|
||||
: { ok: true, page: olderPage }
|
||||
}
|
||||
})
|
||||
const subscribe = vi.fn(
|
||||
@@ -155,8 +163,34 @@ describe('useMobileStructuredAgentState older history', () => {
|
||||
const { hook, historyCursors } = await mountWithOlderPages({}, range(800, 1_000, 'task-1'))
|
||||
|
||||
await vi.waitFor(() => expect(historyCursors).toEqual([800]))
|
||||
await vi.waitFor(() => expect(hook.current!.loadingOlder).toBe(false))
|
||||
await vi.waitFor(() => expect(hook.current!.olderPages).toBe('idle'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(historyCursors).toEqual([800])
|
||||
})
|
||||
|
||||
it('keeps the chat readable when an older page fails, retries on a tap, and ends at unreadable history', async () => {
|
||||
const older: Parameters<typeof mountWithOlderPages>[0] = { 1000: new Error('timed out') }
|
||||
const { hook } = await mountWithOlderPages(older)
|
||||
|
||||
act(() => hook.current!.loadEarlier())
|
||||
await vi.waitFor(() => expect(hook.current!.olderPages).toBe('failed'))
|
||||
expect(hook.current!.state.status).toBe('ready')
|
||||
expect(hook.current!.state.items.map((item) => item.sequence)).toEqual([1_000])
|
||||
|
||||
older[1000] = page(range(900, 1_000), true)
|
||||
older[900] = Object.assign(new Error('agent_session_journal_unreadable'), {
|
||||
data: {
|
||||
refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
|
||||
}
|
||||
})
|
||||
act(() => hook.current!.loadEarlier())
|
||||
await vi.waitFor(() => expect(hook.current!.state.items[0]?.sequence).toBe(900))
|
||||
expect(hook.current!.olderPages).toBe('idle')
|
||||
|
||||
act(() => hook.current!.loadEarlier())
|
||||
await vi.waitFor(() => expect(hook.current!.olderPages).toBe('unavailable'))
|
||||
expect(hook.current!.state.status).toBe('ready')
|
||||
act(() => hook.current!.loadEarlier())
|
||||
expect(hook.current!.olderPages).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { openTranscriptAfterHold } from './mobile-structured-transcript-subscription'
|
||||
import { startMobileStructuredTranscriptRead } from './mobile-structured-transcript-read'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types'
|
||||
import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer'
|
||||
import type {
|
||||
AgentSessionHistoryPage,
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire'
|
||||
import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder'
|
||||
import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
oldestStructuredAgentSessionCursor,
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
type MobileQueuedMessageFeed,
|
||||
type MobileQueuePause
|
||||
} from './mobile-structured-queued-message-feed'
|
||||
import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-older-page-loader'
|
||||
|
||||
type QueuedFeed = {
|
||||
messages: MobileQueuedMessageFeed
|
||||
@@ -37,10 +37,6 @@ type QueuedFeed = {
|
||||
const NO_QUEUED_FEED: QueuedFeed = { messages: null, pause: null }
|
||||
|
||||
const MAX_RETAINED_SESSION_STATES = 32
|
||||
/** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */
|
||||
const OLDER_PAGE_ANCHOR_ATTEMPTS = 3
|
||||
/** Bounds the pages one Load-earlier reads past that hold only subagent rows. */
|
||||
const OLDER_PAGES_PER_LOAD = 8
|
||||
|
||||
export function useMobileStructuredAgentState(args: {
|
||||
client: RpcClient | null
|
||||
@@ -57,8 +53,9 @@ export function useMobileStructuredAgentState(args: {
|
||||
queuedMessages: MobileQueuedMessageFeed
|
||||
/** The whole queue's pause, published with the drafts. */
|
||||
queuePause: MobileQueuePause
|
||||
loadingOlder: boolean
|
||||
loadEarlier: () => void
|
||||
olderPages: OlderPagePhase
|
||||
/** `settled`: the page waits for it before it lands (the phone page, until the reader stops). */
|
||||
loadEarlier: (settled?: () => Promise<void>) => void
|
||||
} {
|
||||
const { client, connected, enabled, sessionId, sessionKey } = args
|
||||
// Keep a bounded cache so offline tab switches select the right transcript
|
||||
@@ -74,14 +71,11 @@ export function useMobileStructuredAgentState(args: {
|
||||
const queued =
|
||||
(enabled && sessionKey ? queuedBySession.get(sessionKey) : undefined) ?? NO_QUEUED_FEED
|
||||
const queuedMessages = queued.messages
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const { phase: olderPages, load: loadOlderPages, supersede } = useMobileOlderPageLoader()
|
||||
const stateRef = useRef(state)
|
||||
const sessionKeyRef = useRef(sessionKey)
|
||||
const streamGenerationRef = useRef(0)
|
||||
useLayoutEffect(() => {
|
||||
stateRef.current = state
|
||||
sessionKeyRef.current = sessionKey
|
||||
}, [sessionKey, state])
|
||||
}, [state])
|
||||
|
||||
const apply = useCallback(
|
||||
(action: StructuredAgentSessionAction) => {
|
||||
@@ -151,9 +145,7 @@ export function useMobileStructuredAgentState(args: {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
streamGenerationRef.current += 1
|
||||
sessionKeyRef.current = sessionKey
|
||||
setLoadingOlder(false)
|
||||
supersede()
|
||||
if (!client || !sessionId || !enabled) {
|
||||
return
|
||||
}
|
||||
@@ -163,7 +155,7 @@ export function useMobileStructuredAgentState(args: {
|
||||
return
|
||||
}
|
||||
apply({ type: 'loading' })
|
||||
const generation = streamGenerationRef.current
|
||||
let streamCurrent = true
|
||||
const holderId = structuredAgentSessionHolderId('mobile-chat')
|
||||
const held = callAgentSession(client, 'agentSession.hold', {
|
||||
sessionId,
|
||||
@@ -173,12 +165,23 @@ export function useMobileStructuredAgentState(args: {
|
||||
sessionId,
|
||||
open: (onFrame) => openTranscriptAfterHold(client, sessionId, held, onFrame),
|
||||
read: (request) => callAgentSession(client, 'agentSession.currentRequests', request),
|
||||
apply,
|
||||
apply: (action) => {
|
||||
if (
|
||||
action.type === 'event' &&
|
||||
(action.event.type === 'snapshot' || action.event.type === 'reset')
|
||||
) {
|
||||
// A replaced window ends an older read that was for the old one.
|
||||
supersede()
|
||||
}
|
||||
apply(action)
|
||||
},
|
||||
applyQueued,
|
||||
error: applyReadFailure,
|
||||
isCurrent: () => generation === streamGenerationRef.current
|
||||
isCurrent: () => streamCurrent
|
||||
})
|
||||
return () => {
|
||||
streamCurrent = false
|
||||
supersede()
|
||||
endStream()
|
||||
void held
|
||||
.then(() =>
|
||||
@@ -195,86 +198,84 @@ export function useMobileStructuredAgentState(args: {
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [apply, applyQueued, applyReadFailure, client, connected, enabled, sessionId, sessionKey])
|
||||
}, [
|
||||
apply,
|
||||
applyQueued,
|
||||
applyReadFailure,
|
||||
client,
|
||||
connected,
|
||||
enabled,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
supersede
|
||||
])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
const current = stateRef.current
|
||||
if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) {
|
||||
return
|
||||
}
|
||||
if (!oldestStructuredAgentSessionCursor(current)) {
|
||||
return
|
||||
}
|
||||
const requestSessionKey = sessionKey
|
||||
const requestGeneration = streamGenerationRef.current
|
||||
const isCurrentRead = (): boolean =>
|
||||
sessionKeyRef.current === requestSessionKey &&
|
||||
streamGenerationRef.current === requestGeneration
|
||||
setLoadingOlder(true)
|
||||
void (async () => {
|
||||
// A live batch can head-trim past the anchor mid-read, and the reducer drops that
|
||||
// page rather than leave a hole in the transcript. Re-anchor and retry.
|
||||
for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) {
|
||||
const cursor = oldestStructuredAgentSessionCursor(stateRef.current)
|
||||
if (!cursor || !isCurrentRead()) {
|
||||
return
|
||||
const loadEarlier = useCallback(
|
||||
(settled?: () => Promise<void>) => {
|
||||
const start = oldestStructuredAgentSessionCursor(stateRef.current)
|
||||
if (!client || !sessionId || !sessionKey || !stateRef.current.hasOlder || !start) {
|
||||
return
|
||||
}
|
||||
let cursor = start
|
||||
// Every head this tap produced. Any other head means a live batch trimmed the window mid-tap,
|
||||
// and the reducer drops a page that no longer abuts it, so the read restarts from that head.
|
||||
// Holding them all keeps the check right while `stateRef` lags a commit behind a landed page.
|
||||
let heads = new Set([start.sequence])
|
||||
const intact = (): boolean => {
|
||||
const head = oldestStructuredAgentSessionCursor(stateRef.current)
|
||||
return head?.epoch === cursor.epoch && heads.has(head.sequence)
|
||||
}
|
||||
loadOlderPages(async ({ timeoutMs, isCurrent }) => {
|
||||
if (!intact()) {
|
||||
const head = oldestStructuredAgentSessionCursor(stateRef.current)
|
||||
if (!head) {
|
||||
return 'end'
|
||||
}
|
||||
cursor = head
|
||||
heads = new Set([head.sequence])
|
||||
}
|
||||
// Mobile draws only the session's own rows, so a page of a subagent's rows alone
|
||||
// would land as nothing; read on until a page adds a row the reader can see. Older
|
||||
// hosts send one for any burst; current ones when the page's byte bound cuts it short.
|
||||
const pages: {
|
||||
requestedCursor: AgentJournalCursor
|
||||
page: AgentSessionHistoryPage
|
||||
}[] = []
|
||||
let requestedCursor = cursor
|
||||
while (pages.length < OLDER_PAGES_PER_LOAD) {
|
||||
const result = await callAgentSession<AgentSessionHistoryResult>(
|
||||
let result: AgentSessionHistoryResult
|
||||
try {
|
||||
result = await callAgentSession<AgentSessionHistoryResult>(
|
||||
client,
|
||||
'agentSession.history',
|
||||
{
|
||||
sessionId,
|
||||
direction: 'before',
|
||||
cursor: requestedCursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
}
|
||||
{ sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT },
|
||||
timeoutMs
|
||||
)
|
||||
if (!result.ok || !isCurrentRead()) {
|
||||
break
|
||||
}
|
||||
pages.push({ requestedCursor, page: result.page })
|
||||
if (
|
||||
!result.page.hasOlder ||
|
||||
result.page.items.length === 0 ||
|
||||
result.page.items.some(isRootAgentJournalItem)
|
||||
) {
|
||||
break
|
||||
}
|
||||
requestedCursor = result.page.window.nextCursor
|
||||
} catch (error) {
|
||||
return isFinalAgentSessionReadRefusal(agentSessionReadFailureRefusal(error))
|
||||
? 'unavailable'
|
||||
: 'failed'
|
||||
}
|
||||
if (pages.length === 0 || !isCurrentRead()) {
|
||||
return
|
||||
// Lands once the reader stops scrolling, so nothing grows above them mid-fling.
|
||||
if (settled) {
|
||||
await settled()
|
||||
}
|
||||
// The reducer drops a page whose anchor slid, so only an intact anchor lands;
|
||||
// each later page abuts the one before it.
|
||||
if (oldestStructuredAgentSessionCursor(stateRef.current)?.sequence === cursor.sequence) {
|
||||
for (const { requestedCursor: pageCursor, page } of pages) {
|
||||
apply({ type: 'older-page', requestedCursor: pageCursor, page })
|
||||
}
|
||||
return
|
||||
if (!isCurrent()) {
|
||||
return 'end'
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (isCurrentRead()) {
|
||||
applyReadFailure(error)
|
||||
if (!result.ok) {
|
||||
// Every reset reason makes the subscription reload a fresh window, which replaces this one.
|
||||
return 'end'
|
||||
}
|
||||
if (!intact()) {
|
||||
return 'drew-nothing'
|
||||
}
|
||||
const { page } = result
|
||||
apply({ type: 'older-page', requestedCursor: cursor, page })
|
||||
const oldest = page.items[0]
|
||||
if (!page.hasOlder || !oldest) {
|
||||
return 'end'
|
||||
}
|
||||
heads.add(oldest.sequence)
|
||||
cursor = page.window.nextCursor
|
||||
// Mobile draws only the session's own rows, so a page of a subagent's rows alone lands as
|
||||
// nothing; the loader reads on until a page adds a row the reader can see.
|
||||
return page.items.some(isRootAgentJournalItem) ? 'drew' : 'drew-nothing'
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentRead()) {
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
})
|
||||
}, [apply, applyReadFailure, client, loadingOlder, sessionId, sessionKey])
|
||||
},
|
||||
[apply, client, loadOlderPages, sessionId, sessionKey]
|
||||
)
|
||||
|
||||
// A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled
|
||||
// to ask for more, so it reads back once from each such head. A first page can be one: an
|
||||
@@ -284,13 +285,18 @@ export function useMobileStructuredAgentState(args: {
|
||||
? `${sessionKey}:${state.epoch}:${state.items[0]?.sequence}`
|
||||
: null
|
||||
const readBackFromRef = useRef<string | null>(null)
|
||||
const loadingEarlier = olderPages === 'loading'
|
||||
useEffect(() => {
|
||||
if (drawsNothingFrom === null || loadingOlder || readBackFromRef.current === drawsNothingFrom) {
|
||||
if (
|
||||
drawsNothingFrom === null ||
|
||||
loadingEarlier ||
|
||||
readBackFromRef.current === drawsNothingFrom
|
||||
) {
|
||||
return
|
||||
}
|
||||
readBackFromRef.current = drawsNothingFrom
|
||||
loadEarlier()
|
||||
}, [drawsNothingFrom, loadEarlier, loadingOlder])
|
||||
}, [drawsNothingFrom, loadEarlier, loadingEarlier])
|
||||
|
||||
return { state, stateRef, queuedMessages, queuePause: queued.pause, loadingOlder, loadEarlier }
|
||||
return { state, stateRef, queuedMessages, queuePause: queued.pause, olderPages, loadEarlier }
|
||||
}
|
||||
|
||||
@@ -170,8 +170,12 @@ describe('the page keeps its own capture buffer', () => {
|
||||
const posted: Array<Record<string, unknown>> = []
|
||||
const mounted = mountTerminalWebDocument(host, (message) => posted.push(message))
|
||||
|
||||
window.dispatchEvent(new ErrorEvent('error', { message: 'first failure' }))
|
||||
window.dispatchEvent(new ErrorEvent('error', { message: 'second failure' }))
|
||||
window.dispatchEvent(
|
||||
new ErrorEvent('error', { message: 'first failure', error: new Error('first failure') })
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new ErrorEvent('error', { message: 'second failure', error: new Error('second failure') })
|
||||
)
|
||||
|
||||
const messages = posted.filter((message) => message.type === 'error').map((m) => m.message)
|
||||
expect(messages[0]).toContain('captured: first failure')
|
||||
@@ -182,12 +186,27 @@ describe('the page keeps its own capture buffer', () => {
|
||||
const nextHost = plantHost()
|
||||
const nextPosted: Array<Record<string, unknown>> = []
|
||||
const next = mountTerminalWebDocument(nextHost, (message) => nextPosted.push(message))
|
||||
window.dispatchEvent(new ErrorEvent('error', { message: 'third failure' }))
|
||||
window.dispatchEvent(
|
||||
new ErrorEvent('error', { message: 'third failure', error: new Error('third failure') })
|
||||
)
|
||||
const nextMessages = nextPosted.filter((m) => m.type === 'error').map((m) => m.message)
|
||||
expect(nextMessages[0]).toContain('captured: third failure')
|
||||
expect(nextMessages[0]).not.toContain('first failure')
|
||||
next.dispose()
|
||||
})
|
||||
|
||||
it("leaves a browser notice from elsewhere on the page out, so it can't fail a terminal", () => {
|
||||
// The chat list's resize observer can make the browser raise its loop notice; no error is behind it.
|
||||
const host = plantHost()
|
||||
const posted: Array<Record<string, unknown>> = []
|
||||
const mounted = mountTerminalWebDocument(host, (message) => posted.push(message))
|
||||
|
||||
const message = 'ResizeObserver loop completed with undelivered notifications.'
|
||||
window.dispatchEvent(new ErrorEvent('error', { message }))
|
||||
|
||||
expect(posted.filter((m) => m.type === 'error')).toEqual([])
|
||||
mounted.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a start that throws gives the host back', () => {
|
||||
|
||||
@@ -217,6 +217,11 @@ function startPageDocument(
|
||||
// `stopHostNotify` takes it off with everything else.
|
||||
installErrorReporter: (report) => {
|
||||
const errorListener = (event: ErrorEvent) => {
|
||||
// The window is the whole app's: a browser notice with no error behind it (a ResizeObserver
|
||||
// loop elsewhere on the page) is not this terminal failing, and before ready it is fatal.
|
||||
if (!event.error) {
|
||||
return
|
||||
}
|
||||
report(event.message, event.filename, event.lineno, event.colno, event.error)
|
||||
}
|
||||
window.addEventListener('error', errorListener)
|
||||
|
||||
Reference in New Issue
Block a user