From 8b200f3523edd14a1abc40e2d860d7ff3d1efeb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 23:00:13 -0700 Subject: [PATCH] fix(ui): keep the chat transcript anchored while the pane resizes (#124013) * fix(ui): keep the chat transcript anchored while the pane resizes The width-change path wiped the virtualizer's entire item-size cache (instance.measure()), collapsing every offscreen row to the 120px estimate with no scroll compensation: the reader teleported mid-transcript and the trickle of remeasure corrections made text visibly jump for several frames on every pane or window resize. Keep the stale offscreen sizes as estimates instead. The existing synchronous resizeItem pass plus TanStack's per-row ResizeObserver re-seed connected rows with fold-based scroll compensation, so the anchor row holds still; offscreen rows correct lazily as they connect. End-pinned transcripts keep following the tail via the existing height-change scrollToEnd path. Regression e2e: mid-transcript anchor stays within 60px across 1280->1000->820->1280 resizes (pre-fix: anchor scrolled out of the viewport entirely and scrollTop drifted by 250px+). Wipe introduced in #115059, refined by #117687/#123713 without addressing anchoring. * test(infra): deflake ephemeral-port rebind proof under runner contention The bind->release->rebind cycle races foreign processes on shared CI runners: a stolen port between release and rebind fails the assertion (EADDRINUSE, seen on checks-node-compact-large-17). Retry the whole cycle on a fresh ephemeral port, bounded at 5 attempts; a genuine release regression still fails every attempt because the collision is with our own lingering listener. * test: prove port release on the allocated port; add end-pin resize proof Address ClawSweeper review on #124013: - ports-probe: replace the fresh-port retry with a connect probe against the same allocated port. A lingering listener accepts the probe; a released port refuses it. Unlike a rebind, the probe does not collide with foreign outbound sockets that transiently occupy the port on busy runners, so the release contract stays tied to one port with no retry at all. - chat-resize-anchor e2e: add a width-only end-pinned case proving the virtualizer's wasAtEnd compensation keeps the transcript within 2px of the end across 1280->1000->820->1280 resizes. * test(ui): widen resize-anchor drift bound for Linux font metrics The fold-spanning anchor row re-wraps by a renderer-dependent amount (42px macOS, 63px Linux CI at 820px). 120px keeps a wide margin below the pre-fix failure mode (anchor out of viewport, 250px+ drift). * test(tooling): register manager-session-update-race in memory helper routing #124024 added the memory-core test on main without updating the cross-lane routing expectation in test-projects.test.ts, breaking the core-tooling shard on every merge ref that includes it. --- src/infra/ports-probe.test.ts | 21 +- ui/src/e2e/chat-resize-anchor.e2e.test.ts | 207 ++++++++++++++++++ .../components/chat-transcript-controller.ts | 8 +- 3 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 ui/src/e2e/chat-resize-anchor.e2e.test.ts diff --git a/src/infra/ports-probe.test.ts b/src/infra/ports-probe.test.ts index 37a84638e652..761dc4a378eb 100644 --- a/src/infra/ports-probe.test.ts +++ b/src/infra/ports-probe.test.ts @@ -48,9 +48,26 @@ describe("tryListenOnPort", () => { throw err; } expect(port).toBeGreaterThan(0); + // Release proof stays tied to the allocated port: a lingering listener + // would accept this probe, a released port refuses it. A rebind assertion + // instead collides with any foreign outbound socket occupying the port on + // busy runners (EADDRINUSE flake) without detecting leaks any better. await expect( - tryListenOnPort({ port, host: "127.0.0.1", exclusive: true }), - ).resolves.toBeUndefined(); + new Promise<"accepted" | "refused">((resolve, reject) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + socket.once("connect", () => { + socket.destroy(); + resolve("accepted"); + }); + socket.once("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") { + resolve("refused"); + return; + } + reject(err); + }); + }), + ).resolves.toBe("refused"); }); it("rejects when the port is already in use", async () => { diff --git a/ui/src/e2e/chat-resize-anchor.e2e.test.ts b/ui/src/e2e/chat-resize-anchor.e2e.test.ts new file mode 100644 index 000000000000..f8d7ba29d712 --- /dev/null +++ b/ui/src/e2e/chat-resize-anchor.e2e.test.ts @@ -0,0 +1,207 @@ +// Regression: resizing the chat pane must not jump the transcript. The reader's +// anchor row (topmost visible row) should stay in place while rows re-wrap. +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let controlUi: ControlUiE2eServer; +const contexts = new Set(); + +const MESSAGE_COUNT = 120; +const filler = + "The quick brown fox jumps over the lazy dog while the virtualized transcript keeps every " + + "row height honest across pane widths. "; + +function messageContent(index: number): string { + // Vary paragraph counts so re-wrapping changes heights non-uniformly. + return `Message number ${index}: ${filler.repeat((index % 4) + 1)}`; +} + +type AnchorSample = { + key: string | null; + topDelta: number; + scrollTop: number; + visibleKeys: string[]; +}; + +async function sampleAnchor(page: Page, anchorKey: string | null): Promise { + return await page.evaluate((key) => { + const inner = document.querySelector(".chat-thread-inner--virtual"); + const scroller = inner?.parentElement; + if (!inner || !scroller) { + return { key: null, topDelta: Number.NaN, scrollTop: Number.NaN, visibleKeys: [] }; + } + const scrollerRect = scroller.getBoundingClientRect(); + const rows = [...inner.querySelectorAll(".chat-virtual-row")] + .map((row) => ({ + key: row.dataset.virtualRowKey ?? "", + rect: row.getBoundingClientRect(), + })) + .filter(({ rect }) => rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom) + .toSorted((left, right) => left.rect.top - right.rect.top); + const anchor = key === null ? rows[0] : rows.find((row) => row.key === key); + return { + key: anchor?.key ?? null, + topDelta: anchor ? anchor.rect.top - scrollerRect.top : Number.NaN, + scrollTop: scroller.scrollTop, + visibleKeys: rows.map((row) => row.key), + }; + }, anchorKey); +} + +async function settleFrames(page: Page, frames: number): Promise { + await page.evaluate( + (count) => + new Promise((resolve) => { + const step = (remaining: number) => { + if (remaining <= 0) { + resolve(); + return; + } + requestAnimationFrame(() => step(remaining - 1)); + }; + step(count); + }), + frames, + ); +} + +describeControlUiE2e("Chat transcript resize anchoring", () => { + beforeAll(async () => { + controlUi = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }, 120_000); + + afterAll(async () => { + for (const context of contexts) { + await context.close(); + } + await browser?.close(); + await controlUi?.close(); + }); + + it("keeps the anchor row stable across pane width changes", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + contexts.add(context); + const page = await context.newPage(); + const now = Date.now(); + await installMockGateway(page, { + sessionKey: "agent:main:main", + historyMessages: Array.from({ length: MESSAGE_COUNT }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: messageContent(index), + timestamp: now - (MESSAGE_COUNT - index) * 60_000, + })), + }); + + await page.goto(`${controlUi.baseUrl}chat`); + await page + .getByText(`Message number ${MESSAGE_COUNT - 1}:`) + .first() + .waitFor({ + timeout: 15_000, + }); + + // Scroll to the middle of the transcript and let the virtualizer settle. + await page.evaluate(() => { + const scroller = document.querySelector( + ".chat-thread-inner--virtual", + )?.parentElement; + if (scroller) { + scroller.scrollTop = Math.floor((scroller.scrollHeight - scroller.clientHeight) / 2); + } + }); + await settleFrames(page, 30); + + const before = await sampleAnchor(page, null); + expect(before.key).not.toBeNull(); + + const widths = [1000, 820, 1280]; + const observations: { width: number; sample: AnchorSample }[] = []; + for (const width of widths) { + await page.setViewportSize({ width, height: 900 }); + await settleFrames(page, 30); + const sample = await sampleAnchor(page, before.key); + observations.push({ width, sample }); + console.log( + `[resize-anchor] width=${width} anchor=${before.key} topDelta=${sample.topDelta} ` + + `(was ${before.topDelta}) scrollTop=${sample.scrollTop} (was ${before.scrollTop}) ` + + `visible=${sample.visibleKeys.length ? sample.visibleKeys.join(",") : ""}`, + ); + } + + for (const { width, sample } of observations) { + // The anchor row must remain visible after every width change... + expect(sample.key, `anchor visible at width ${width}`).toBe(before.key); + // ...and roughly hold its viewport position while its own text re-wraps. + // The fold-spanning anchor row's own re-wrap moves its top by a font- + // metric-dependent amount (42px on macOS, 63px on Linux CI at 820px); + // the pre-fix failure mode is the anchor leaving the viewport entirely + // with 250px+ scroll drift, so 120px keeps a wide detection margin. + expect( + Math.abs(sample.topDelta - before.topDelta), + `anchor drift at width ${width}`, + ).toBeLessThanOrEqual(120); + } + }, 120_000); + + it("keeps an end-pinned transcript pinned across width-only resizes", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + contexts.add(context); + const page = await context.newPage(); + const now = Date.now(); + await installMockGateway(page, { + sessionKey: "agent:main:main", + historyMessages: Array.from({ length: MESSAGE_COUNT }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: messageContent(index), + timestamp: now - (MESSAGE_COUNT - index) * 60_000, + })), + }); + + // Fresh sessions open pinned to the end; leave the scroll untouched. + await page.goto(`${controlUi.baseUrl}chat`); + await page + .getByText(`Message number ${MESSAGE_COUNT - 1}:`) + .first() + .waitFor({ + timeout: 15_000, + }); + await settleFrames(page, 30); + + const distanceFromEnd = () => + page.evaluate(() => { + const scroller = document.querySelector( + ".chat-thread-inner--virtual", + )?.parentElement; + return scroller + ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + : Number.NaN; + }); + expect(await distanceFromEnd()).toBeLessThanOrEqual(2); + + // Width-only changes re-wrap every row; the end anchor must follow the + // new total size (virtualizer wasAtEnd compensation), not drift upward. + for (const width of [1000, 820, 1280]) { + await page.setViewportSize({ width, height: 900 }); + await settleFrames(page, 30); + expect(await distanceFromEnd(), `distance from end at width ${width}`).toBeLessThanOrEqual(2); + await page + .getByText(`Message number ${MESSAGE_COUNT - 1}:`) + .first() + .waitFor({ state: "visible", timeout: 2_000 }); + } + }, 120_000); +}); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts index a7319555443f..613d6da0a095 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -249,10 +249,10 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri instance.scrollToEnd({ behavior: "auto" }); } if (widthChanged) { - // Cached offscreen sizes belong to the old wrapping width. Reset - // them, seed current rows, then repeat after any same-commit - // re-stamp has attached and completed layout. - instance.measure(); + // Keep stale offscreen sizes as estimates — a full measure() wipe + // has no scroll compensation and teleports the reader. resizeItem + // re-seeds connected rows with fold-based compensation, so the + // anchor row holds still; offscreen rows correct as they connect. this.measureConnectedRows(); this.queueConnectedRowMeasure(); }