mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
fix(markdown): preserve reader position while responses grow
Keep the same message component boundary when a live row becomes retained history, preserving Mermaid runtimes and selection nodes. Observe paced child text commits at the web viewport so sticky-bottom follows before the next frame.
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
reportScrollJumps,
|
||||
scrollCadences,
|
||||
withVariedTimeline,
|
||||
withStreamingReader,
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
@@ -150,3 +152,18 @@ for (const cadence of scrollCadences) {
|
||||
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
|
||||
await expectImageSpaceReserved(page, testInfo);
|
||||
});
|
||||
|
||||
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
|
||||
await withStreamingReader(async (agent) => {
|
||||
const reading = await holdParagraphThroughCompletion(page, agent);
|
||||
expect(
|
||||
reading.maxFollowGap,
|
||||
"follow-bottom must catch each streamed commit before the next frame",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
reading.maxDrift,
|
||||
"streaming must preserve the held paragraph's viewport position",
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { seedMockAgentWorkspace, openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
|
||||
|
||||
export const scrollCadences = [
|
||||
@@ -44,6 +44,104 @@ interface TimelinePage {
|
||||
at: number;
|
||||
}
|
||||
|
||||
export async function withStreamingReader(run: (agent: MockAgentWorkspace) => Promise<void>) {
|
||||
const section = "Read this paragraph while the response continues below. ".repeat(8);
|
||||
const response = [
|
||||
"```mermaid\nflowchart LR\n Start --> Review --> Done\n```",
|
||||
"```mermaid\nflowchart LR\n Nested --> Finished\n```",
|
||||
...Array.from({ length: 40 }, (_, index) => `## Long section ${index}\n\n${section}`),
|
||||
"READER-STREAM-SETTLED",
|
||||
].join("\n\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "held-stream-reader-",
|
||||
title: "Held streaming paragraph",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: response,
|
||||
mockStreamingAssistantIntervalMs: 1,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await run(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export async function holdParagraphThroughCompletion(page: Page, agent: MockAgentWorkspace) {
|
||||
await openAgentRoute(page, agent);
|
||||
await page.getByRole("textbox", { name: "Message agent...", exact: true }).waitFor();
|
||||
const scroll = page.getByTestId("agent-chat-scroll");
|
||||
const following = await scroll.evaluateHandle((viewport) => {
|
||||
const result = { stopped: false, maxGap: 0 };
|
||||
const frame = () => {
|
||||
if (result.stopped) return;
|
||||
result.maxGap = Math.max(
|
||||
result.maxGap,
|
||||
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
|
||||
);
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
return result;
|
||||
});
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream the mixed response.");
|
||||
await page
|
||||
.getByRole("heading", { name: "Long section 12", exact: true })
|
||||
.waitFor({ state: "attached" });
|
||||
const maxFollowGap = await following.evaluate((result) => {
|
||||
result.stopped = true;
|
||||
return result.maxGap;
|
||||
});
|
||||
const bounds = await scroll.boundingBox();
|
||||
if (!bounds) throw new Error("Reader viewport is missing");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.wheel(0, -1600);
|
||||
await page.waitForTimeout(150);
|
||||
const paragraph = page.getByRole("heading", { name: "Long section 3", exact: true });
|
||||
await paragraph.waitFor({ state: "attached" });
|
||||
const recording = await paragraph.evaluateHandle((heading) => {
|
||||
const viewport = document.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]')!;
|
||||
const clip = viewport.getBoundingClientRect();
|
||||
viewport.scrollTop += heading.getBoundingClientRect().y - (clip.top + clip.height / 2);
|
||||
const baseline = heading.getBoundingClientRect().y;
|
||||
const result = { maxDrift: 0, retained: true, done: false };
|
||||
let stableAt = performance.now();
|
||||
let signature = "";
|
||||
const frame = (now: number) => {
|
||||
result.retained &&= heading.isConnected;
|
||||
const current = [...document.querySelectorAll("h2")].find(
|
||||
(n) => n.textContent === "Long section 3",
|
||||
);
|
||||
if (current)
|
||||
result.maxDrift = Math.max(
|
||||
result.maxDrift,
|
||||
Math.abs(current.getBoundingClientRect().y - baseline),
|
||||
);
|
||||
const next = `${viewport.scrollHeight}:${viewport.scrollTop}:${current?.getBoundingClientRect().y}`;
|
||||
if (next !== signature) {
|
||||
signature = next;
|
||||
stableAt = now;
|
||||
}
|
||||
const complete =
|
||||
document.body.textContent?.includes("Worked for") &&
|
||||
document.body.textContent.includes("READER-STREAM-SETTLED") &&
|
||||
!document.querySelector('[data-testid="turn-working-indicator"]');
|
||||
if (complete && now - stableAt >= 750) {
|
||||
result.done = true;
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
return result;
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("scroll-to-bottom-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
await page.waitForFunction((result) => result.done, recording);
|
||||
return { ...(await recording.jsonValue()), maxFollowGap };
|
||||
}
|
||||
|
||||
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
|
||||
export async function withVariedTimeline(
|
||||
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
|
||||
|
||||
@@ -978,14 +978,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
// ResizeObserver runs before paint. Following in the next frame exposes
|
||||
// the newly grown row at the old offset for one painted frame.
|
||||
forceStickToBottom();
|
||||
});
|
||||
observer.observe(scrollContainer);
|
||||
if (contentNode) {
|
||||
observer.observe(contentNode);
|
||||
}
|
||||
// Paced reveal commits inside a message without rerendering this viewport.
|
||||
// Observe those commits too, so the next frame sees the grown content at the
|
||||
// bottom instead of waiting for the resize notification after animation frames.
|
||||
const contentChanges = new MutationObserver(() => {
|
||||
if (isActiveRef.current && followOutputRef.current) forceStickToBottom();
|
||||
});
|
||||
if (contentNode)
|
||||
contentChanges.observe(contentNode, { childList: true, characterData: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
contentChanges.disconnect();
|
||||
};
|
||||
}, [
|
||||
reconcileReadingPosition,
|
||||
@@ -993,7 +1004,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
]);
|
||||
|
||||
@@ -218,12 +218,13 @@ function renderListEmptyComponent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
|
||||
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
|
||||
// Live and retained rows use the same boundary so completion preserves the message
|
||||
// subtree, including measured diagrams and selections. FlatList recreates history
|
||||
// cells with fresh refs and shifted indexes on data changes. This boundary is
|
||||
// what stops that churn: a row renders again only when its stream item identity, its layout item
|
||||
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
|
||||
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
|
||||
const HistoryStreamRow = memo(function HistoryStreamRow({
|
||||
const StreamRow = memo(function StreamRow({
|
||||
layoutItem,
|
||||
renderStreamItem,
|
||||
}: {
|
||||
@@ -234,7 +235,7 @@ const HistoryStreamRow = memo(function HistoryStreamRow({
|
||||
return <>{renderStreamItem(layoutItem)}</>;
|
||||
});
|
||||
|
||||
function renderHistoryStreamItem(input: {
|
||||
function renderStreamRow(input: {
|
||||
item: StreamItem;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
@@ -244,7 +245,7 @@ function renderHistoryStreamItem(input: {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HistoryStreamRow
|
||||
<StreamRow
|
||||
item={input.item}
|
||||
layoutItem={layoutItem}
|
||||
renderStreamItem={input.renderStreamItem}
|
||||
@@ -252,18 +253,6 @@ function renderHistoryStreamItem(input: {
|
||||
);
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
item: StreamItem;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
}): ReactNode {
|
||||
const layoutItem = input.layoutItemById.get(input.item.id);
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
return input.renderStreamItem(layoutItem);
|
||||
}
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
@@ -1025,7 +1014,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderHistoryRow = useCallback(
|
||||
(item: StreamItem) =>
|
||||
renderHistoryStreamItem({
|
||||
renderStreamRow({
|
||||
item,
|
||||
layoutItemById: layoutHistoryItemById,
|
||||
renderStreamItem,
|
||||
@@ -1046,7 +1035,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
// to be a new object on every text-chunk flush.
|
||||
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
|
||||
(item: StreamItem) =>
|
||||
renderLiveHeadStreamItem({
|
||||
renderStreamRow({
|
||||
item,
|
||||
layoutItemById: layoutLiveHeadItemById,
|
||||
renderStreamItem,
|
||||
|
||||
Reference in New Issue
Block a user