Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 168836c97e test(app): reproduce native turn summary scroll jumps 2026-10-28 04:17:14 +00:00
3 changed files with 279 additions and 0 deletions
@@ -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.
@@ -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}`,
);
@@ -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 (
<View ref={ref} collapsable={false} style={style}>
<Pressable testID={item.id} onPress={onPress}>
<Text style={textStyle}>
{item.id}
{isHost ? stateLabel : ""}
</Text>
</Pressable>
</View>
);
}
function measureY(view: View | undefined): Promise<number> {
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<void> {
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<StreamViewportHandle | null>(null);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const [running, setRunning] = useState(false);
const [report, setReport] = useState("Ready");
const rows = useRef(new Map<string, View>());
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 (
<Row
item={item}
isHost={!!turn}
expanded={!!turn?.expanded}
attach={attach}
toggle={toggle}
/>
);
};
const start = useCallback(() => setRunning(true), []);
const finish = useCallback(
() => setTimeout(() => sample("host-9", () => setRunning(false)), 5000),
[sample],
);
return (
<View style={frameStyle}>
<Text testID="result" style={resultStyle}>
{report}
</Text>
<Button title="Start running turn" onPress={start} />
<Button title="Finish in 5 seconds" onPress={finish} />
<View style={listStyle}>
{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,
})}
</View>
</View>
);
}
export default function Diagnostic() {
return (
<SafeAreaProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<Transcript />
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}