Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] f792ac8a4e chore: update panel scrolling branch from main 2026-11-06 07:49:20 +00:00
paseo-bot[bot] e8a9e2d699 chore(android): carry panel scrolling fix onto the current timeline 2026-11-06 07:48:53 +00:00
paseo-bot[bot]andluckxine 5feec6fa35 fix(android): contain nested panel motion in the current timeline
Identify the forward-order timeline by its existing native identity so panel edges stay still while ordinary parents retain Android handoff. Preserve the native content-position corrections.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-06 07:46:51 +00:00
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
5 changed files with 213 additions and 3 deletions
+11 -2
View File
@@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
lists the content-position corrections and the React Native issues they work around.
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
identify the timeline for this rule.
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
handoff with the real compiled scroll views:
```bash
cd packages/app/android
@@ -0,0 +1,158 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
@RunWith(AndroidJUnit4::class)
class PanelEdgeTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
private lateinit var manager: ReactScrollViewManager
@After fun finish() {
instrumentation.runOnMainSync { activity?.finish() }
}
@Test fun panelBottomEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its bottom", 600, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelTopEdgeLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 0)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, -100, null)
child.stopNestedScroll()
assertEquals("Panel stays at its top", 0, child.scrollY)
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
}
}
@Test fun panelFlingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync {
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
}
}
@Test fun disablingChildNestedScrollPreventsHandoff() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.isNestedScrollingEnabled = false
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
assertEquals(400, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedScroll(0, 0, 0, 100, null)
child.stopNestedScroll()
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
}
}
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
manager.setNativeId(parent, "ordinary-scroll-parent")
child.scrollTo(0, 600)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.dispatchNestedFling(0f, 1500f, false)
child.stopNestedScroll()
}
SystemClock.sleep(500)
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
}
@Test fun consumedPanelFlingDoesNotStartParentFling() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
child.stopNestedScroll()
assertEquals(400, parent.scrollY)
}
}
@Test fun panelInteriorScrollingLeavesTimelineStill() {
val (parent, child) = openPanel()
instrumentation.runOnMainSync {
child.scrollTo(0, 100)
assertEquals(100, child.scrollY)
assertEquals(400, parent.scrollY)
parent.scrollTo(0, 500)
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
}
}
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
val started = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var parent: ReactScrollView
lateinit var child: ReactScrollView
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val react = ScrollTestContext(started.applicationContext)
val context = ThemedReactContext(react, started)
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
child = manager.createViewInstance(context)
child.isNestedScrollingEnabled = true
val content = ReactViewGroup(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
parent.addView(content)
val childContent = ReactViewGroup(context)
child.addView(childContent)
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
parent.layout(0, 0, 300, 400)
content.layout(0, 0, 300, 2000)
child.layout(0, 400, 300, 600)
childContent.layout(0, 0, 300, 800)
parent.scrollTo(0, 400)
}
return Pair(parent, child)
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's own event sink for a context without JavaScript. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -12,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
@@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView {
super(context);
}
// A panel owns its gesture through its edges; remaining child motion must not move the chat
// behind it. The timeline's native identity stays present when its end pin is removed for
// reading history. Ordinary parents retain Android's nested-scroll handoff.
@Override
public void onNestedScroll(
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
if (isTimeline()) return;
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
if (isTimeline() && !consumed) return true;
return super.onNestedFling(target, velocityX, velocityY, consumed);
}
private boolean isTimeline() {
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
}
@Override
public void setMaintainVisibleContentPosition(
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
@@ -675,6 +675,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
// The Android scroll owner identifies the timeline to contain nested panel gestures.
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}