From 3a6d6db9a508f39e9ada90ea885ec9a5a89cd197 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 6 Aug 2026 22:19:20 +0200 Subject: [PATCH] fix(app): anchor image growth during timeline reload Intrinsic image dimensions can arrive after the timeline has painted, outside the React segment-change path. Let the browser preserve the visible content anchor during that resize instead of exposing a frame before the deferred bottom correction. --- .../browser/agent-tab-image-stability.spec.ts | 127 +++++++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 1 - 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 08014cfe4..08cf98d3c 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -1,4 +1,4 @@ -import { test as base } from "../support/fixtures"; +import { expect, test as base } from "../support/fixtures"; import { appendSettledTimelineTurns, createNearTenMegabyteAssistantPng, @@ -45,6 +45,131 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered", await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent }); }); +test("reloading a timeline anchors near-tail assistant image growth", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Reload geometry image", + fileName: "reload-geometry-image.png", + }); + const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry"); + await emitSettledAssistantImage(workspace.client, imageAgent, image); + await appendSettledTimelineTurns(workspace.client, imageAgent, 3); + + await page.addInitScript( + ({ accessibleName }) => { + interface ImageGeometryEvent { + atMs: number; + complete: boolean; + height: number; + naturalHeight: number; + naturalWidth: number; + scrollTop: number; + width: number; + } + const state = { + accessibleName, + events: [] as ImageGeometryEvent[], + mutationObserver: null as MutationObserver | null, + resizeObserver: null as ResizeObserver | null, + target: null as Element | null, + }; + const record = () => { + const target = state.target; + if (!target) return; + const imageElement = + target instanceof HTMLImageElement ? target : target.querySelector("img"); + const scroll = target.closest('[data-testid="agent-chat-scroll"]'); + const rect = target.getBoundingClientRect(); + const event: ImageGeometryEvent = { + atMs: performance.now(), + complete: imageElement?.complete ?? false, + height: rect.height, + naturalHeight: imageElement?.naturalHeight ?? 0, + naturalWidth: imageElement?.naturalWidth ?? 0, + scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1, + width: rect.width, + }; + const previous = state.events.at(-1); + if ( + !previous || + previous.complete !== event.complete || + previous.height !== event.height || + previous.naturalHeight !== event.naturalHeight || + previous.naturalWidth !== event.naturalWidth || + previous.scrollTop !== event.scrollTop || + previous.width !== event.width + ) { + state.events.push(event); + } + }; + const inspect = () => { + const target = Array.from(document.querySelectorAll('[role="img"]')).find( + (element) => element.getAttribute("aria-label") === accessibleName, + ); + if (!target || target === state.target) { + record(); + return; + } + state.target = target; + state.resizeObserver?.disconnect(); + state.resizeObserver = new ResizeObserver(record); + state.resizeObserver.observe(target); + record(); + }; + const start = () => { + state.mutationObserver = new MutationObserver(inspect); + state.mutationObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["src", "style"], + childList: true, + subtree: true, + }); + inspect(); + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start, { once: true }); + } else { + start(); + } + ( + window as unknown as { + __paseoReloadImageGeometry?: typeof state; + } + ).__paseoReloadImageGeometry = state; + }, + { accessibleName: image.alt }, + ); + + await openAssistantImageTimeline(page, imageAgent); + await expectAssistantImageRendered(page, image); + await page.reload(); + await expectAssistantImageRendered(page, image); + await page.waitForTimeout(500); + + const events = await page.evaluate(() => { + const state = ( + window as unknown as { + __paseoReloadImageGeometry?: { events: Array }; + } + ).__paseoReloadImageGeometry; + if (!state) throw new Error("Reload image geometry observer was not installed"); + return state.events; + }); + const geometryEvents = events as Array<{ height: number; scrollTop: number }>; + const initialGeometry = geometryEvents.find((event) => event.height > 0); + if (!initialGeometry) + throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`); + const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height); + if (!expandedGeometry) + throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`); + expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan( + initialGeometry.scrollTop, + ); +}); + test("a real assistant PNG remains reachable through pagination and remount", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 2c0864061..08781f80b 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1031,7 +1031,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool minHeight: 0, overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", - overflowAnchor: "none", overscrollBehaviorY: "contain", }; }, [scrollEnabled]);