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} + +