mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168836c97e |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user