Skip to content

fix(mobile): page native remote history until the reader sees an older turn - #3177

Merged
wgqqqqq merged 2 commits into
GCWing:mainfrom
wgqqqqq:wgq/mobile-history-paging
Sep 21, 2026
Merged

wgqqqqq merged 2 commits into
GCWing:mainfrom
wgqqqqq:wgq/mobile-history-paging

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Native mobile clients (iOS, Android, HarmonyOS) and the mobile-web client page
older remote session history through read_stream. This change makes one
history request walk far enough to actually reveal an older turn, keeps the
reading position while it does, and stops the page burst from rewriting the
transcript once per record.

Fixes: no tracked issue.

Type and Areas

Type: bug fix / regression fix, with the performance and UI work that the same
code path requires.

Areas: native mobile (src/apps/mobile/{ios,android,harmonyos}), shared Kotlin
mobile core (core-transport, core-feature), mobile web, shared relay
transport (src/shared/relay-transport), docs.

Motivation / Impact

Host pages are cut by sequence, and a record's sequence is the moment it was
last updated, so one long turn owns every record it produced. A request that
read exactly one page could therefore deliver nothing but more records of the
turn already on screen: the client reported "history loaded" while the
transcript above stayed identical, and the newest turn of a long session can
hold several pages by itself.

What changes for users:

  • One older-history gesture now walks up to four pages and stops as soon as it
    delivers a turn the transcript did not already have, the host runs out, or the
    budget is spent. The next gesture continues from where it stopped.
  • The loading indicator covers the whole walk, including the reducer's work: the
    buffered Kotlin transport waits for downstream consumption before it reports
    caught-up or completes a request, so enqueueing records no longer reads as
    completion.
  • The reading position survives the prepend, and a page cannot be requested
    twice: one automatic page per deliberate drag, nothing queued while loading,
    and no request from layout, anchor correction, or released-finger overscroll.
  • iOS starts the transcript at the top overlay's inset instead of 22pt below it,
    so a session's first message sits where Android and HarmonyOS already put it.
  • iOS gains the composer dismiss gesture (a deliberate downward drag on the
    input row dismisses the keyboard, matching the transcript's own scroll view).
  • Subagent children that arrive nested inside a Task and again flat behind it
    are folded once instead of drawn twice.
  • A running-input acknowledgement no longer consumes the newly submitted bubble
    through turn-based deduplication.

What changes for developers:

  • A history request always gets an answer. A stream lane that closes now fails
    queued requests instead of leaving their callers suspended for the life of the
    app, and wakes coalesce before the lane serves them, so a hint burst costs one
    catch-up read and a history request queued during the burst is served in that
    same pass instead of behind one refresh per hint.
  • A page burst no longer rewrites the transcript per record: writes are deferred
    until the page settles, a write reuses the rows it wrote last time and appends
    only what changed, and a turn renders once until its records or controls
    change. Disconnect, session switch, and delete drop the cached rows.
  • docs/architecture/peer-device-mode.md records the record-page, delivery, and
    timeline boundaries. These stay client-internal: nothing was added to the
    read_stream wire format, and MAX_HISTORY_PAGES_PER_REQUEST is mirrored
    between the Kotlin core transport and src/shared/relay-transport/HostStream.ts.

Verification

Commands run on this branch (65 files, +2483/-444):

Check Result
node --test src/apps/mobile/harmonyos/tools/tests/{host-stream,session-record,history-page-arrival}.test.cjs 25 passed
cd src/apps/mobile/shared && ./gradlew :core-feature:jvmTest :core-transport:jvmTest 733 passed, 1 skipped, 0 failed
cd src/apps/mobile/android && ./gradlew :app:testDebugUnitTest 56 passed, including the new HistoryPageArrivalTrackerTest
cd src/apps/mobile/ios && ./Testing/run-pure-swift-tests.sh passed (composer dismiss gesture, history page gesture gate, timeline snapshot, process grouping, startup reveal)
cd src/apps/mobile/ios && xcodebuild -project OpenBitFun.xcodeproj -scheme OpenBitFun -configuration Debug -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build ** BUILD SUCCEEDED **
cd src/mobile-web && pnpm run test:host-stream 16 passed
cd src/mobile-web && npx tsc --noEmit clean
pnpm run mobile:ui:check contract and generated files in sync

Not run, and not claimed:

  • pnpm run mobile:architecture fails on this branch and identically on the
    base commit
    (d06151243, verified in a clean worktree): sharedReachesPlatformTrees
    flags core-transport/src/jvmTest/.../ClientBuildContractTest.kt and
    defaultArgsInFeatureApi flags AccountUiState.kt, RemoteSidebarPresentation.kt,
    ConversationModels.kt. None of those files are touched here; this is a
    pre-existing base failure.
  • Android instrumented tests (ConversationViewTest, ChatMessageBubbleTest)
    and the iOS simulator UI tests need a device/simulator and were not run here,
    so the prepend anchor, overscroll, and keyboard-dismiss behavior is covered by
    unit tests only on this branch.
  • The iOS transcript content start is backed by the device build above only:
    no simulator or on-device capture of the new spacing was taken on this branch.
  • Remote scenarios exercised: Remote control (native mobile clients as
    controllers over the host stream), at the protocol/reducer/unit level. The
    other three scenarios (remote workspace, peer device mode, detached dispatch)
    are not touched by this change.

Reviewer Notes

  • The paging budget is deliberately a bound, not a loop-until-done: a request is
    a user gesture, not an unbounded download. Reviewers may want to argue about
    the value 4 and about whether the "stops when it shows an older turn" test —
    turn membership derived from turn.turnId on session-record payloads — is
    the right progress signal.
  • RemoteSessionStore is the only writer of the remote transcript table, which
    is what makes the reuse-unchanged-rows write legal. If that ever stops being
    true, forgetWrittenTranscript and the append path need a rethink.
  • The replica's per-turn render cache is keyed by a generation counter bumped on
    every mutation that can change what a turn renders to; the deletion path
    falls back to clearing the whole cache when the record id does not name a
    whole turn.
  • No upgrade-compatibility surface changed: no persisted field was added,
    removed, or repurposed, and the SQLite path only appends rows it previously
    wrote.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. (An unrelated local MiniApp/Demo/pocket-island/ directory was left untracked and is not part of this branch.)
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable. (docs/architecture/peer-device-mode.md; the removed Android strings were unused entries, and the iOS Localizable.xcstrings entries were the matching unused copies.)

…r turn

Host pages are cut by sequence, and a record's sequence is the moment it
was last updated, so one long turn owns every record it produced. Reading
a single page per request could therefore deliver nothing but more
records of the turn already on screen: the timeline reported "history
loaded" while the transcript above stayed identical, and the newest turn
of a long session can hold several pages by itself.

One request now walks up to four pages and stops as soon as it delivers a
turn the transcript did not have, the host runs out, or the budget is
spent; the next gesture continues from where it stopped. The request
reports one settled history state instead of one per page, and the
loading indicator covers the whole walk: the buffered Kotlin transport
waits for downstream consumption before it reports caught-up or completes
a request, so enqueueing records no longer reads as completion.

A request is a user gesture waiting outside the flow, so the lane owes
every one an answer: a lane that closes now fails queued requests instead
of leaving their callers suspended for the life of the app. Wakes
coalesce before the lane serves them, so a hint burst costs one catch-up
read and a history request that arrived during the burst is served in
that same pass instead of behind one refresh per hint.

The burst no longer rewrites the transcript per record either. Writes are
deferred until a page settles, a write reuses the rows it wrote last time
and appends only what changed, and a turn renders once until its records
or controls change. Disconnect, session switch and delete drop the cached
rows.

iOS, Android and HarmonyOS keep the reading position while paging: one
automatic page per deliberate drag, no request queued while loading,
anchors preserved on prepend, and no request from layout, anchor
correction or released-finger overscroll. iOS also gains the composer
dismiss gesture, and subagent children that arrive nested and again flat
are folded once instead of drawn twice. A running-input acknowledgement no
longer consumes the newly submitted bubble through turn-based
deduplication.

docs/architecture/peer-device-mode.md records the record-page, delivery
and timeline boundaries. These are client-internal boundaries, not
additions to the read_stream wire format.
The timeline carried the design token `timeline_top_padding` (22) as its
own top padding, but the top overlay is already reserved through
`safeAreaInset`, and that inset ends with the 28pt header edge fade. The
first message therefore began 50pt below the header band at rest, while
Android's `contentPadding(top = topInset)` and HarmonyOS's
`contentStartOffset(topInset)` start it at 28pt.

Dropping the extra padding gives the three clients one content start and
leaves the fade doing what it is for: softening text that scrolls under
the band. The token stays in the contract, where only the two design
galleries consume it.
@wgqqqqq
wgqqqqq merged commit 791173c into GCWing:main Sep 21, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant