From 4dfd7c18a0986c635ce81535e60b8f61d031ab75 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 18:15:14 +0200 Subject: [PATCH] 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. --- .../agent-timeline-scroll-smoothness.spec.ts | 17 +++ .../helpers/timeline-scroll-smoothness.ts | 100 +++++++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 15 ++- packages/app/src/agent-stream/view.tsx | 27 ++--- 4 files changed, 137 insertions(+), 22 deletions(-) diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index feceed7869..f3cddf3617 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -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); + }); +}); diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index eee771369a..1b7522e54c 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -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) { + 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('[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, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 150d18326f..d66c80e049 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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, ]); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 18c39f6d31..aa398c4b52 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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; renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; @@ -244,7 +245,7 @@ function renderHistoryStreamItem(input: { return null; } return ( - ; - 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 - renderHistoryStreamItem({ + renderStreamRow({ item, layoutItemById: layoutHistoryItemById, renderStreamItem, @@ -1046,7 +1035,7 @@ const AgentStreamViewComponent = forwardRef - renderLiveHeadStreamItem({ + renderStreamRow({ item, layoutItemById: layoutLiveHeadItemById, renderStreamItem,