Skip to content

fix(app): keep the Android chat still when answer text is tapped or long-pressed - #1843

Open
chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/android-text-focus-scroll
Open

chphch wants to merge 1 commit into
slopus:mainfrom
chphch:fix/android-text-focus-scroll

Conversation

@chphch

@chphch chphch commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

On Android, tapping or long-pressing selectable text in a long agent answer scrolls the chat away before anything can be selected: text near the top of an answer sends the list to the bottom, text near its end sends it back up. Selectable text takes focus on a tap and on a long-press, and ReactScrollView then brings the focused view into view, working out where it is from layout offsets only (ReactScrollView.scrollToChild via offsetDescendantRectToMyCoords, and TextView.bringPointIntoView via View.requestRectangleOnScreen), which skip transforms. The inverted FlashList rotates every row by 180°, so inside a row that position comes out mirrored across the row's middle, and in an answer taller than the screen the mirrored spot is off screen. This replaces the Android vertical ScrollView with a ReactScrollView subclass that, when a transform sits between the focused view and the scroll view, maps rectangles through each view's matrix (so dragging a selection handle toward an edge still scrolls the right way) and does not scroll for a focus change in touch mode. A config plugin copies the Kotlin into the prebuilt project and registers its package ahead of MainReactPackage, because the bridgeless view-manager lookup takes the first package that answers for RCTScrollView. Scroll views with no transform in that path behave exactly as before. Agent answers keep native selection, as 0e8392314 left them; only the scroll changes. It is a native change, so it ships with a build, not an OTA.

Proof

Android emulator (API 36, x86_64 debug build of this branch on main 8517ab23). For the "main" runs the same build had the plugin's one add(0, …) line commented out, so the stock view was in use; dumpsys activity top shows com.facebook.react.views.scroll.ReactScrollView there and engineering.happy.scroll.TransformAwareScrollView with this PR. The session's latest message is a 40-paragraph agent answer.

Start, main after the long-press, this PR after the long-press

Side-by-side recording: main jumps to the end of the answer, this PR stays put

  • Long-press near the top of the answer ("Paragraph 02", answer scrolled to its start): on main the list moves from 3240 dp to 288 dp in a single scroll event, so the word selected under the finger ends up off screen. With this PR there is no scroll event; the word is selected where it was pressed and the system Copy / Share / Select all toolbar opens above it. Dragging a selection handle extends the selection without scrolling (checked on my fork's build of this same commit).
  • Plain tap near the end of the answer ("Paragraph 36", list at the bottom): on main the list moves from 0 dp to 2626 dp. With this PR nothing moves.

Why no scroll at all on a touch-mode focus change

Mapping the rectangle correctly is not enough on its own. In touch mode, focus lands either under the finger, which is already on screen, or where the framework puts it by itself: when a recycled row unmounts the focused text, ViewGroup.removeViewInternal calls rootViewRequestFocus(), and the first focusable view it finds is usually nowhere near the reader. With the scroll mapped but still performed, that moved the list by 10,472 px while I was scrolling past the row I had tapped earlier:

Trace (an earlier revision of this branch that still scrolled on focus)
TransformAwareScrollView.scrollFocusedIntoView
TransformAwareScrollView.requestChildFocus
android.view.ViewGroup.requestChildFocus
android.view.View.handleFocusGainInternal
android.view.View.requestFocus
android.view.View.rootViewRequestFocus
android.view.ViewGroup.removeViewInternal
android.view.ViewGroup.removeViewAt
com.facebook.react.views.view.ReactClippingViewManager.removeViewAt
com.facebook.react.fabric.mounting.SurfaceMountingManager.removeViewAt

Keyboard and D-pad navigation run outside touch mode and still bring the newly focused view into view, with the mapped rectangle.

🤖 Generated with Claude Code

…ong-pressed

Tapping selectable text in a long agent answer, or long-pressing it to
copy, scrolled the chat away: text near the top of an answer sent the
list to the bottom, text near its end sent it back up.

Selectable text takes focus on a tap and on a long-press, and the
ScrollView then brings the focused view into view. It works out where
that view is from layout positions alone (ReactScrollView.scrollToChild,
View.requestRectangleOnScreen), skipping every transform. An inverted
FlashList rotates each row by 180 degrees, so inside a row the position
comes out mirrored across the row's middle. In an answer taller than the
screen the mirrored position is off screen, and the list jumps there.

The vertical ScrollView is now a ReactScrollView subclass. When a
transform sits between the focused view and the scroll view, it maps
rectangles through each view's matrix, so dragging a selection handle
still scrolls toward where the text really is. In touch mode it does not
scroll for a focus change at all: focus lands under the finger, or the
framework moves it on its own when a recycled row takes the focused text
with it, handing it to the first focusable view, far from the reader.
Keyboard navigation still scrolls, mapped the same way. Scroll views
with no transform in the path behave exactly as before.

A config plugin copies the Kotlin sources into the generated project and
registers the package ahead of MainReactPackage, because React Native
uses the first package that answers for "RCTScrollView". This needs a
native build; an OTA update cannot carry it.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

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