From 168836c97e5c48174d0250b06a944ac42905f209 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:17:14 +0000
Subject: [PATCH] test(app): reproduce native turn summary scroll jumps
---
packages/app/e2e/mobile/turn-scroll/README.md | 42 ++++
.../app/e2e/mobile/turn-scroll/android.mjs | 40 ++++
.../app/e2e/mobile/turn-scroll/native.tsx | 197 ++++++++++++++++++
3 files changed, 279 insertions(+)
create mode 100644 packages/app/e2e/mobile/turn-scroll/README.md
create mode 100644 packages/app/e2e/mobile/turn-scroll/android.mjs
create mode 100644 packages/app/e2e/mobile/turn-scroll/native.tsx
diff --git a/packages/app/e2e/mobile/turn-scroll/README.md b/packages/app/e2e/mobile/turn-scroll/README.md
new file mode 100644
index 0000000000..8f91849851
--- /dev/null
+++ b/packages/app/e2e/mobile/turn-scroll/README.md
@@ -0,0 +1,42 @@
+# Native turn reading-position diagnostic
+
+Reproduction for [#8057](https://github.com/getpaseo/paseo/issues/8057).
+This uses the production turn presentation and native stream strategy with fixed-height
+message rows. It isolates viewport behavior; it does not exercise a provider, the full
+conversation screen, iOS, or the precise automatic-collapse reading-row report.
+
+Build the Android development client from this checkout. In a disposable checkout,
+temporarily replace `packages/app/index.ts` with:
+
+```tsx
+import "./src/polyfills/install-early";
+import "./src/styles/unistyles";
+import { registerRootComponent } from "expo";
+import Diagnostic from "./e2e/mobile/turn-scroll/native";
+registerRootComponent(Diagnostic);
+```
+
+Start Metro from `packages/app` on an available port:
+
+```sh
+APP_VARIANT=development npx expo start --lan --port 8097
+```
+
+With an Android emulator running, forward that port with `adb reverse tcp:8097 tcp:8097`.
+From the repository root, run:
+
+```sh
+node packages/app/e2e/mobile/turn-scroll/android.mjs turn-scroll http://127.0.0.1:8097
+```
+
+The test expands the middle summary, brings it back into view, collapses it, and
+asserts its native window Y stays within two points. Baseline fails with approximately
+`expand=-450.3, collapse=450.3`. Measurements use `measureInWindow`, not accessibility
+bounds of off-screen rows.
+
+The separate buttons expose the latest turn's running-to-finished projection with a
+five-second delay. They measure its summary before and after completion, rather than
+claiming to follow a disappearing reading row.
+
+Restore `index.ts`, close the device session, stop Metro, and stop the emulator after use.
+This is an intentionally failing diagnostic, not a production fix or a CI unit test.
diff --git a/packages/app/e2e/mobile/turn-scroll/android.mjs b/packages/app/e2e/mobile/turn-scroll/android.mjs
new file mode 100644
index 0000000000..fa04321709
--- /dev/null
+++ b/packages/app/e2e/mobile/turn-scroll/android.mjs
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+
+const [, , session, metroUrl] = process.argv;
+assert(session && metroUrl, "Pass a session name and the Metro URL serving native.tsx");
+function ad(...args) {
+ return execFileSync("agent-device", [...args, "--session", session], {
+ encoding: "utf8",
+ timeout: 60_000,
+ });
+}
+ad(
+ "open",
+ "sh.paseo.debug",
+ `exp+voice-mobile://expo-development-client/?url=${encodeURIComponent(metroUrl)}`,
+ "--platform",
+ "android",
+ "--relaunch",
+);
+ad("wait", "text", "Ready");
+ad("press", 'id="host-7"', "--settle");
+ad("wait", "text", "expand host-7: before=");
+const expansion = ad("get", "text", 'id="result"');
+console.log(expansion.trim());
+const expandedDelta = Number(/delta=([\d.-]+)/.exec(expansion)?.[1]);
+ad("scroll", "up", "--until", 'id="host-7"');
+ad("wait", "stable", "1000", "10000");
+ad("press", 'id="host-7"', "--settle");
+ad("wait", "text", "collapse host-7: before=");
+const collapse = ad("get", "text", 'id="result"');
+console.log(collapse.trim());
+const collapsedDelta = Number(/delta=([\d.-]+)/.exec(collapse)?.[1]);
+assert(
+ Number.isFinite(expandedDelta) && Number.isFinite(collapsedDelta),
+ "Both native measurements must complete",
+);
+assert(
+ Math.abs(expandedDelta) <= 2 && Math.abs(collapsedDelta) <= 2,
+ `The summary must stay under the reader: expand=${expandedDelta}, collapse=${collapsedDelta}`,
+);
diff --git a/packages/app/e2e/mobile/turn-scroll/native.tsx b/packages/app/e2e/mobile/turn-scroll/native.tsx
new file mode 100644
index 0000000000..9582ccd01a
--- /dev/null
+++ b/packages/app/e2e/mobile/turn-scroll/native.tsx
@@ -0,0 +1,197 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import { Button, Pressable, Text, View, type ViewStyle } from "react-native";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { KeyboardProvider } from "react-native-keyboard-controller";
+import { KeyboardShiftProvider } from "@/keyboard/shift";
+import { createNativeStreamStrategy } from "@/agent-stream/strategy-native";
+import { createStreamPresentation } from "@/agent-stream/presentation";
+import type { StreamViewportHandle } from "@/agent-stream/strategy";
+import type { StreamItem } from "@/types/stream";
+
+const frameStyle: ViewStyle = { flex: 1, paddingTop: 45, paddingBottom: 30 };
+const listStyle: ViewStyle = { flex: 1 };
+const resultStyle = { color: "#111", height: 50 };
+const textStyle = { color: "#111", padding: 8 };
+const rowStyles: ViewStyle[] = [false, true].flatMap((work) =>
+ [false, true].map((host) => ({
+ height: work ? 90 : 60,
+ borderBottomWidth: 1,
+ borderColor: "#bbb",
+ backgroundColor: host ? "#ccddff" : "#fff",
+ justifyContent: "center",
+ })),
+);
+function Row({
+ item,
+ expanded,
+ isHost,
+ attach,
+ toggle,
+}: {
+ item: StreamItem;
+ expanded: boolean;
+ isHost: boolean;
+ attach(id: string, view: View | null): void;
+ toggle(id: string): void;
+}) {
+ const ref = useCallback((view: View | null) => attach(item.id, view), [attach, item.id]);
+ const onPress = useCallback(() => toggle(item.id), [toggle, item.id]);
+ const style = rowStyles[(item.id.startsWith("work") ? 2 : 0) + (isHost ? 1 : 0)];
+ const stateLabel = expanded ? " Collapse" : " Expand";
+ return (
+
+
+
+ {item.id}
+ {isHost ? stateLabel : ""}
+
+
+
+ );
+}
+function measureY(view: View | undefined): Promise {
+ if (!view) return Promise.reject(new Error("Diagnostic row is not mounted"));
+ return new Promise((resolve) => view.measureInWindow((_x, y) => resolve(y)));
+}
+function settleLayout(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 1500));
+}
+const strategy = createNativeStreamStrategy();
+const tail: StreamItem[] = Array.from({ length: 10 }, (_, n) => {
+ const turnId = `turn-${n}`;
+ const timestamp = new Date(2026, 0, 1, 0, n);
+ return [
+ { kind: "user_message" as const, id: `host-${n}`, turnId, timestamp, text: `Prompt ${n}` },
+ ...Array.from({ length: 5 }, (_work, i) => ({
+ kind: "assistant_message" as const,
+ id: `work-${n}-${i}`,
+ turnId,
+ timestamp,
+ text: `Work ${n}-${i}`,
+ })),
+ { kind: "assistant_message" as const, id: `reply-${n}`, turnId, timestamp, text: `Reply ${n}` },
+ ];
+}).flat();
+
+function Transcript() {
+ const present = useMemo(createStreamPresentation, []);
+ const viewportRef = useRef(null);
+ const [expanded, setExpanded] = useState>(new Set());
+ const [running, setRunning] = useState(false);
+ const [report, setReport] = useState("Ready");
+ const rows = useRef(new Map());
+ const presentation = present({
+ tail,
+ head: [],
+ transform: undefined,
+ display: "collapseTurns",
+ expandedTurnHostIds: expanded,
+ isTurnActive: running,
+ activeTurnId: running ? "turn-9" : null,
+ });
+ const historyMounted = strategy.orderTail(presentation.tail);
+ const sample = useCallback(async (id: string, next: () => void, label = "complete") => {
+ const before = await measureY(rows.current.get(id));
+ next();
+ await settleLayout();
+ const after = await measureY(rows.current.get(id));
+ setReport(
+ `${label} ${id}: before=${before.toFixed(1)} after=${after.toFixed(1)} delta=${(after - before).toFixed(1)}`,
+ );
+ }, []);
+ const attach = useCallback((id: string, view: View | null) => {
+ if (view) rows.current.set(id, view);
+ else rows.current.delete(id);
+ }, []);
+ const toggle = useCallback(
+ (id: string) => {
+ const turn = presentation.turns.turnsByHostId.get(id);
+ if (!turn) return;
+ sample(
+ id,
+ () => {
+ viewportRef.current?.holdReadingPosition();
+ setExpanded((previous) => {
+ const next = new Set(previous);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ },
+ turn.expanded ? "collapse" : "expand",
+ );
+ },
+ [presentation.turns.turnsByHostId, sample],
+ );
+ const renderRow = (item: StreamItem) => {
+ const turn = presentation.turns.turnsByHostId.get(item.id);
+ return (
+
+ );
+ };
+ const start = useCallback(() => setRunning(true), []);
+ const finish = useCallback(
+ () => setTimeout(() => sample("host-9", () => setRunning(false)), 5000),
+ [sample],
+ );
+ return (
+
+
+ {report}
+
+
+
+
+ {strategy.render({
+ agentId: "diagnostic",
+ segments: { historyVirtualized: [], historyMounted, liveHead: [] },
+ liveHeadRowRevision: expanded,
+ historyRowRevision: {
+ contentById: new Set(),
+ displayStateById: new Set([...expanded, ...presentation.turns.turnsByHostId.keys()]),
+ globalDisplayState: running,
+ },
+ readingRowReplacements: presentation.readingRowReplacements,
+ boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
+ renderers: {
+ renderHistoryVirtualizedRow: renderRow,
+ renderHistoryMountedRow: renderRow,
+ renderLiveHeadRow: renderRow,
+ renderLiveAuxiliary: () => null,
+ },
+ listEmptyComponent: null,
+ viewportRef,
+ routeBottomAnchorRequest: null,
+ isAuthoritativeHistoryReady: true,
+ onNearBottomChange: () => {},
+ onNearHistoryStart: () => false,
+ isLoadingOlderHistory: false,
+ hasOlderHistory: false,
+ olderHistoryProgressKey: null,
+ scrollEnabled: true,
+ listStyle: { flex: 1 },
+ baseListContentContainerStyle: {},
+ forwardListContentContainerStyle: {},
+ contentMaxWidth: 400,
+ })}
+
+
+ );
+}
+export default function Diagnostic() {
+ return (
+
+
+
+
+
+
+
+ );
+}