mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +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