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.
This commit is contained in:
Peter Steinberger
2026-08-14 23:00:13 -07:00
committed by GitHub
parent a02ed2cfda
commit 8b200f3523
3 changed files with 230 additions and 6 deletions
+19 -2
View File
@@ -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 () => {
+207
View File
@@ -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<BrowserContext>();
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<AnchorSample> {
return await page.evaluate((key) => {
const inner = document.querySelector<HTMLElement>(".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<HTMLElement>(".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<void> {
await page.evaluate(
(count) =>
new Promise<void>((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<HTMLElement>(
".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(",") : "<none>"}`,
);
}
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<HTMLElement>(
".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);
});
@@ -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();
}