Skip to content

fix(mobile): a streaming reply can't pull a drag back to the bottom - #23832

Open
brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/mobile-chat-drag-phases
Open

brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/mobile-chat-drag-phases

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​159 $\color{#cf222e}{\Huge{\mathbf{−}}}$​111 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​48
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​22 $\color{#cf222e}{\Huge{\mathbf{−}}}$​13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​9

ELI5

On iPhone, while an agent is typing a reply, the chat keeps nudging itself to the bottom so you see the newest words. If you tried to scroll up to read something earlier, the chat often yanked you right back down. Now, once your finger is on the list, the chat leaves you alone until you let go, and only goes back to following the reply if you let go at the bottom.

What Changed

Before: the chat's scroll owner tracked the user's gesture with a single "user is scrolling" flag. While a reply streams, it pins the list to the bottom with an instant (non-animated) scroll about every 60 ms. iOS reports a "momentum scroll ended" event after every instant programmatic scroll, even while a finger is still down and even though no "momentum scroll began" came first. The chat treated that event as "the user's gesture finished", decided the user was back at the bottom, and turned following back on a few milliseconds into the drag. The next pin then pulled the user back down. On the simulator, 5 to 6 of 10 upward drags were yanked while a reply was streaming, and 0 of 10 when nothing was streaming.

After: the gesture is tracked as phases: dragging, released, momentum, idle. Each native scroll event is honored only in the phase that expects it:

  • Drag end is the only way out of dragging, and moves to released.
  • Momentum begin is accepted only as released to momentum.
  • Momentum end is accepted only in momentum, so the event iOS emits after a programmatic scroll is ignored while dragging, after release, or when idle.
  • A release with no momentum settles on the next frame using the drag-end event's position; a release with momentum settles using the momentum-end event's position.
  • Whether to keep following the reply is re-decided from that real position at every gesture end, and turned on again by jump-to-latest or sending a message. The phase is the only new state; the existing follow flag is unchanged.

User effect: dragging up while a reply streams stays where the finger puts it. Letting go at the bottom still resumes following; letting go higher up shows the jump-to-latest button, as before.

The gesture-settling tests moved out of the chat view's test file into a new test file next to the hook, because the view's test file had reached the line limit.

Why

Why phases: the bug is that one flag cannot tell "the finger is down" from "the finger lifted and the list is coasting", so any end event looked like the end of the user's gesture. With phases, a stray event arriving in the wrong phase has no effect by construction, instead of needing a special-case filter.

Why each event only in its phase: the platforms send these events for more than user gestures. On iOS, momentum begin only comes from real deceleration after a finger lift, but momentum end also follows every instant programmatic scroll. On Android, a finger lift (even with zero velocity) normally sends momentum begin and later momentum end, and an animated programmatic scroll sends a begin/end pair too. Accepting momentum begin only right after a release, and momentum end only after that begin, keeps real gestures working on both platforms while ignoring the programmatic ones. Jump-to-latest is instant today; the same rule also covers it if it becomes animated.

Why drag-end vs momentum-end positions: each is the event that describes where the gesture actually ended. Re-reading the position when the frame timer fires would be wrong: if a chunk of the reply lands in that window, the content grows under a finger that was released at the bottom, and the release would read as leaving the bottom.

Alternatives rejected:

  • Ignoring momentum-end events that arrive too soon after a drag starts: a timing guess that still misfires under load and does not describe what the events mean.
  • Moving follow-the-bottom into an inverted list or the native "keep visible content position" option: apps that do this still scroll from JavaScript to stay at the bottom, so the same programmatic scroll events would still arrive mid-drag and the race would remain.

Known weaknesses

  • If on Android the momentum-begin event were ever delivered after the next frame, the release would settle from the drag-end position and the later momentum events would be ignored. The list still ends up where the finger sent it; only the follow decision uses the release point.
  • The phase rules are verified by unit tests against the event sequences each platform sends; live device behavior is being checked on the iOS simulator separately (below). Android has not been exercised on a device.

Linked Issue

None public.

Visual Proof

iOS simulator, live-streaming reply, real 16 ms touch drags (details in the review comment).

With this PR, after dragging up mid-stream: the position holds and the jump-to-latest button shows.

With this PR: position held mid-stream, jump-to-latest shown

Without this PR (current main), one second after the same upward drag: pulled back to the bottom, no jump button.

Without this PR: yanked back to the bottom

With this PR, a small drag released near the bottom resumes following.

With this PR: small drag near the bottom resumes following

Testing

  • New tests, written against the event sequences iOS and Android send: a programmatic momentum-end (alone, and as a begin/end pair) while the finger is down does not resume following; a released drag settles from the release position even when a programmatic momentum-end arrives before the frame. All three fail on the previous code and pass with this change.

  • Existing gesture tests (momentum-end position beats a stale scroll sample, release position used when no momentum follows, repin when content grows between release and settle) moved unchanged in meaning to the hook's test file and pass; the chat view keeps its drag-to-momentum wiring test.

  • From mobile/: full vitest suite passes (889 files), typecheck passes; changed-code quality gate passes.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Mobile-only change to how the chat list reacts to scroll events; no desktop, SSH, wire, or storage impact.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Track the user's scroll gesture as phases (dragging, released, momentum,
idle) and honor each native scroll event only in the phase that expects
it. iOS emits a momentum-end after every non-animated tail pin, even while
a finger is down; it used to end the drag and re-arm tail-follow, so the
next pin yanked the user back to the bottom.

Gesture-settling tests move beside the hook they exercise.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Refactors chat scroll-following logic for mobile.

The PR appears safe to merge; no new actionable issue was established in the PR changes.

Summary

The PR replaces a single active-scroll flag with gesture phases so programmatic momentum-end events cannot resume tail-following during a drag. It moves gesture-settling tests alongside the hook and adds coverage for stray scroll events.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  idle -->|drag begins| dragging
  dragging -->|drag ends| released
  released -->|momentum begins| momentum
  released -->|next frame, no momentum| idle
  momentum -->|momentum ends| idle
Loading

Reviews (3) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 192634b1-cda9-4253-a0d0-bc60464476fc

📥 Commits

Reviewing files that changed from the base of the PR and between 251bb5a and 8f7c6ba.

📒 Files selected for processing (3)
  • mobile/src/session/MobileNativeChatView.test.ts
  • mobile/src/session/use-mobile-native-chat-tail-follow.test.ts
  • mobile/src/session/use-mobile-native-chat-tail-follow.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 4b838c73-c49d-46a5-b063-0054129568c2

📥 Commits

Reviewing files that changed from the base of the PR and between a034cc0 and 251bb5a.

📒 Files selected for processing (2)
  • mobile/src/session/MobileNativeChatView.test.ts
  • mobile/src/session/use-mobile-native-chat-tail-follow.test.ts
💤 Files with no reviewable changes (1)
  • mobile/src/session/MobileNativeChatView.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The mobile chat tail-follow hook now tracks scroll activity through idle, dragging, released, and momentum phases. It accepts drag release only while dragging, starts momentum only after release, and finishes a scroll only during momentum. Tests cover programmatic momentum events, release metrics, settling without momentum, and content growth before settling. Three related tests were removed from the view test file.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 251bb

The change stops streaming replies from pulling an active drag back to the bottom, and the hook tests cover the new behavior. No merge-blocking issue was found. Android and physical-device behavior were not verified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to a034c

The change is confined to on-device chat scrolling. No new access to sensitive data, privileges, or services was identified. Some native event ordering remains unverified on devices.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The identified production reach is the mobile chat list and its local scroll state; the examined handlers do not lead to a privileged or cross-service sink.

Trust Boundaries and Controls

  • observed — Native event geometry determines whether the list is at its tail. The changed drag-end and momentum handlers check phase before allowing those events to complete a gesture.

Resilience and Maintainability Implications

  • observed — A begin event still unconditionally cancels settlement and starts dragging. Comparison with the base revision shows this interruption behavior predates the PR; its occurrence with delayed or synthetic begin events on devices remains unverified.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the mobile streaming-reply drag-to-bottom bug and the intended fix.
Description check ✅ Passed The description is detailed and covers the user impact, implementation, rationale, alternatives, testing, visual proof, scope, and checklist. The linked issue is listed as unavailable, and manual test…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new use-mobile-native-chat-tail-follow.test.ts fails tsc -p tsconfig.test.json, so the check:tests-typecheck ratchet (mobile.yml:127) is red and this can't merge. The PR body's "typecheck passes" is tsc --noEmit (mobile/tsconfig.json), which excludes tests. Fixes are inline.

Reviewed changes

Reviewed the full a034cc0 diff (3 files) — a mobile-only change to how the native chat transcript decides tail-follow.

  • Gestures tracked as phases — use-mobile-native-chat-tail-follow.ts replaces the userScrollActiveRef boolean with an idle/dragging/released/momentum state; each native scroll event is honored only in the phase that expects it.
  • The actual fix — endUserDrag requires dragging, beginMomentum requires released, endMomentum requires momentum. An iOS momentum-end arriving mid-drag (emitted after every instant programmatic pin) is now ignored instead of re-enabling following and letting the next pin yank the list down.
  • Settle source is explicit — release with no momentum settles from the drag-end position on the next frame; release with momentum settles from the momentum-end position. finishUserScroll drops its old "active" guard because every caller is phase-guarded.

I verified the phase tests are real coverage: swapping the hook back to origin/main's implementation makes the three programmatic-momentum / release-position tests fail; they pass on head. The three settling tests also moved from MobileNativeChatView.test.ts intact.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread mobile/src/session/use-mobile-native-chat-tail-follow.test.ts Outdated
Comment thread mobile/src/session/use-mobile-native-chat-tail-follow.test.ts Outdated
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for your review (not merged)

The problem. In the phone app's native chat, while an agent reply is streaming, the chat keeps itself pinned to the bottom with an instant scroll about every 60 ms. iOS reports "scroll momentum ended" after every one of those instant scrolls, even while your finger is still on the screen. The old code read that as "your drag finished at the bottom", turned following back on a few milliseconds into the drag, and the next pin pulled you back down. Scrolling up to read earlier text while a reply streamed often failed.

What changes for the user. Dragging up while a reply streams now stays where your finger puts it, and the jump-to-latest button appears. Letting go near the bottom still resumes following, and tapping jump-to-latest still resumes following. Nothing changes when no reply is streaming.

Review. One review round was run: two independent reviewers covered the event handling on iOS and Android, and the tests, scope and design. The final diff then got a release-readiness pass.

  • Both reviewers checked the phase rules against React Native 0.83.10's own iOS (new architecture) and Android scroll-view source. iOS sends the stray "momentum ended" after each instant scroll; Android sends momentum begin/end after a finger lift and around an animated scroll.
  • Neither found a P0, P1 or P2 issue, and the release-readiness pass agreed. No code changes were needed.
  • Fixed in the PR description (no code change):
    • It said no "user wants to follow" flag is stored. The existing follow flag is still there; the gesture phase is the only new state.
    • It said Android sends momentum-begin on every finger lift. It doesn't when an earlier momentum check is still pending. In that case the release is decided from where the finger lifted, exactly as on main.
  • Deferred (minor):
    • No unit test pins that jump-to-latest, or a new drag, cancels a pending release decision. The code does cancel it.
    • One gap already on main: if a platform sends momentum-begin with no matching momentum-end, following stays off until the next drag, send, or jump-to-latest tap.

Live QA (iOS simulator). A test screen streamed a growing reply every 60 ms, and real finger drags were sent at 16 ms per touch point. Each drag was judged from logged hook state and scroll offsets, not by eye.

Drags pulled back to the bottom
This PR, reply streaming 0 of 23
Current main, reply streaming 9 of 12 (each had 6 to 8 pins land while the finger was still down)
This PR, not streaming 0 of 3

The same PR build also passed four behaviour checks while streaming:

  • Release high up: the position holds and the jump button shows.
  • Tap jump-to-latest: following resumes.
  • Small drag released near the bottom: following resumes.
  • Fast fling upward: it stays where momentum ends.

Screenshots are in the PR description.

Seen during QA; already on main, not caused by this PR. If the transcript gets shorter while you're scrolled away from the bottom, the list lands at the new bottom but following stays off. As new text streams in below, the jump-to-latest button stays hidden until you scroll. This showed up because the test screen resets its fake reply. I did not check whether a real chat ever shrinks mid-stream.

Not verified: Android on a device or emulator, a physical iPhone, a production build, or a real paired desktop host. Typecheck and the full mobile suite were not re-run in this review; the two affected test files pass (35 of 35).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Reviewed the delta since the prior pullfrog review (a034cc0 → 251bb5a): a test-typecheck fix plus a merge of origin/main. These commits address both inline comments from that review; the hook's phase logic is unchanged.

  • act callbacks given braced bodies — the closures passed to act no longer return VitestUtils, satisfying the void callback type at the three flagged sites in use-mobile-native-chat-tail-follow.test.ts.
  • FlatList resolved via root.find — a small hostNamed helper replaces findByType('FlatList'), clearing the ElementType mismatch against the host-string name.

Verified this session: pnpm --dir mobile run check:tests-typecheck exits 0, and both changed test files pass (23 tests). The merge brought no net change to the PR's three files.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

CI fix after the review (no behavior change).

  • verify was red: the new use-mobile-native-chat-tail-follow.test.ts failed the mobile tests-typecheck ratchet (plain tsc --noEmit skips test files, so the review's typecheck didn't see it). It looked the list up with findByType('FlatList'), which React Native's types reject, and passed timer calls that return a value to act. Fixed in 228dcb625d6 by looking the host element up by a string-typed name and wrapping the timer calls; the 35 tests still pass and the ratchet is OK.
  • static analysis then went red on a merge from main: main's fix(tab-bar): keep the active tab visible when the tab strip scrolls #24010 added a Reflect.get in tests/e2e/tab-strip-active-dock.spec.ts, which main fixed in test(e2e): stop main's static analysis failing on the tab strip test #24052. Merging current main again (8f7c6ba7117) picks up that fix. Not caused by this PR.
  • After the merge: mobile src/session suite 207 files / 1,996 tests pass; tests-typecheck ratchet OK; anti-slop lint clean.

HyunCello added a commit to HyunCello/orca that referenced this pull request Oct 3, 2026
Scrolling up in the transcript — releasing toward the bottom, which is where a
reader usually is — was pinned back to the tail mid-gesture, so the fling
appeared to stop dead. Scrolling the other way drifted away from the tail, which
is why only one direction looked broken at first.

iOS emits a bare momentum-end after every non-animated pin and Android a
begin/end pair after an animated one, so an event from a programmatic scroll
could be read as the end of the user's gesture. The gesture is now tracked in
phases — idle, dragging, released, momentum — and each native event is honored
only in the phase that expects it, so a stray event has no effect by
construction.

Cherry-picked from stablyai#23832 (brennanb2025), which is open and
mergeable against upstream main; the commits follow when it lands.

This branch has not been deployed

No deployments
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