From 9d733edb83b7d30c2d03f8627905acd1cbabc306 Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 2 Nov 2026 22:40:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E6=8A=98=E5=8F=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=93=8D=E4=BD=9C=E6=97=B6=E4=B8=8B=E6=96=B9=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8D=E5=86=8D=E9=97=AA=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面端聊天列表把 ResizeObserver 的结算推迟到下一帧(为 macOS WKWebView 加的 useAnimationFrameWithResizeObserver),结算时用的是上一帧记下的行高。 工具组、工具卡、"已处理"的折叠动画每帧都在改行高,下面的行因此总晚一帧, 折叠时两行之间露出最大约 40px 的空白,看上去在闪。 - 推迟结算改为读行的实时布局高度(offsetHeight,不受祖先 transform 影响); ref 回调路径仍返回缓存值,不额外读布局 - 写入时机不变,仍在 rAF 里,不在 WebKit 的 resize 回调里写 DOM - WebUI 的列表在 ResizeObserver 回调里同步结算,没有这个问题,未改 Co-Authored-By: Claude Opus 5.5 --- .../pages/chat/transcript/TranscriptList.tsx | 2 + .../pages/chat/transcript/measureRowLive.ts | 17 ++ .../chat/chat-transcript-scroller.test.mjs | 11 ++ .../chat/transcript-measure-live.test.mjs | 164 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 crates/agent-gui/src/pages/chat/transcript/measureRowLive.ts create mode 100644 crates/agent-gui/test/chat/transcript-measure-live.test.mjs diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index f152dfc3b..2ee2fbf49 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -45,6 +45,7 @@ import { readTranscriptScrollPosition, saveTranscriptScrollPosition, } from "./initialTranscriptLayout"; +import { measureRowLive } from "./measureRowLive"; import { extractRenderUnitRange } from "./renderUnitRangeExtractor"; import { createReplyHoverStore } from "./replyHoverStore"; import { ReplyHoverProvider } from "./rowInteraction"; @@ -288,6 +289,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList }, // Defer measurement-driven DOM writes out of WebKit's resize delivery. useAnimationFrameWithResizeObserver: true, + measureElement: measureRowLive, // Pixel overscan covers the next paint; avoid synchronously rendering // Markdown inside every native scroll event. useFlushSync: false, diff --git a/crates/agent-gui/src/pages/chat/transcript/measureRowLive.ts b/crates/agent-gui/src/pages/chat/transcript/measureRowLive.ts new file mode 100644 index 000000000..edaaafc50 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/transcript/measureRowLive.ts @@ -0,0 +1,17 @@ +import { measureElement, type Virtualizer } from "@tanstack/react-virtual"; + +// The transcript's ResizeObserver flush runs one frame after the delivery it +// carries (useAnimationFrameWithResizeObserver). By then a height animation (a +// collapsing tool group) has written a newer size, and positioning rows from +// the delivered box paints the rows below one frame behind: a gap flashes open +// under the collapsing header. Read the row's live layout height instead +// (offsetHeight, like the observer's box, ignores ancestor transforms); without +// an entry (the ref-callback path) keep the cached size and skip the layout read. +export function measureRowLive( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + instance: Virtualizer, +) { + if (!entry) return measureElement(element, entry, instance); + return element.offsetHeight; +} diff --git a/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs b/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs index 304d75178..a6c452eae 100644 --- a/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs +++ b/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs @@ -69,3 +69,14 @@ test("application chat wrapper preserves the transcript flex height chain", () = /className=\{cn\(\s*"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",\s*containerProps\?\.className,\s*\)\}/s, ); }); + +test("the deferred resize flush measures rows live", () => { + // Behaviour lives in transcript-measure-live.test.mjs; this pins the wiring: + // with the flush deferred a frame, the default measurer sizes rows from a + // frame-old box and a gap flashes under a collapsing tool group. + const options = transcriptListSource.match(/useVirtualizer\(\{[\s\S]*?\n {2}\}\);/); + assert.ok(options, "TranscriptList configures the transcript virtualizer"); + assert.match(options[0], /useAnimationFrameWithResizeObserver:\s*true/); + assert.match(options[0], /^\s*measureElement:\s*measureRowLive,/m); + assert.match(transcriptListSource, /import \{ measureRowLive \} from "\.\/measureRowLive";/); +}); diff --git a/crates/agent-gui/test/chat/transcript-measure-live.test.mjs b/crates/agent-gui/test/chat/transcript-measure-live.test.mjs new file mode 100644 index 000000000..bd474bdd9 --- /dev/null +++ b/crates/agent-gui/test/chat/transcript-measure-live.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHarness } from "../../../virtual-core/test/helpers/harness.mjs"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// The transcript defers ResizeObserver flushes to the next animation frame. +// A collapse animation writes a newer row height before that flush runs, so +// sizing rows from the delivered box painted the rows below one frame behind +// the collapsing row: a gap flashed open under the tool-group header. + +const core = createHarness().core; +const { measureRowLive } = createTsModuleLoader({ + mocks: { "@tanstack/react-virtual": { measureElement: core.measureElement } }, +}).loadModule("src/pages/chat/transcript/measureRowLive.ts"); + +// Fake rows: live layout height (offsetHeight) plus a count of layout reads. +function createRows(indexes, height = 120) { + const rows = new Map(); + for (const index of indexes) { + const row = { index, live: height, reads: 0, node: null }; + row.node = { + isConnected: true, + getAttribute: () => String(index), + get offsetHeight() { + row.reads += 1; + return row.live; + }, + }; + rows.set(index, row); + } + return rows; +} + +function deferredHarness({ measurer, scrollTop, indexes, harnessOptions = {} }) { + const h = createHarness({ scrollAnchoring: "origin", ...harnessOptions }); + let deliver; + h.element.ownerDocument.defaultView.ResizeObserver = class { + constructor(callback) { + deliver = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }; + h.virtualizer.setOptions({ + ...h.virtualizer.options, + useAnimationFrameWithResizeObserver: true, + measureElement: measurer, + }); + h.emitScroll(scrollTop, false); + const rows = createRows(indexes); + for (const row of rows.values()) h.virtualizer.measureElement(row.node); + h.runRafs(); + h.notifies.length = 0; + return { + h, + rows, + // RO delivery carrying the border box observed at delivery time. + deliver: (sizes) => + deliver( + sizes.map(([index, blockSize]) => ({ + target: rows.get(index).node, + borderBoxSize: [{ blockSize, inlineSize: 800 }], + contentRect: { height: blockSize }, + })), + ), + }; +} + +function collapseFrameGap(measurer) { + // Rows 100/101 sit inside the viewport (9880..10480): no anchoring correction. + const { h, rows, deliver } = deferredHarness({ measurer, scrollTop: 9880, indexes: [100, 101] }); + // Frame N: the observer delivers the collapsing row at 80px. + rows.get(100).live = 80; + deliver([[100, 80]]); + // Frame N+1: the animation's rAF (registered earlier) writes 60px first. + rows.get(100).live = 60; + h.runRafs(); + const row100 = h.itemByKey("row-100"); + const row101 = h.itemByKey("row-101"); + return { size: row100.size, gap: row101.start - (row100.start + rows.get(100).live) }; +} + +test("a deferred flush sizes the collapsing row from its live height, so no gap opens", () => { + assert.deepEqual(collapseFrameGap(measureRowLive), { size: 60, gap: 0 }); + // The default measurer reproduces the flicker: the frame-old box leaves 20px. + assert.deepEqual(collapseFrameGap(core.measureElement), { size: 80, gap: 20 }); +}); + +test("the ref-callback path keeps the cached size without reading layout", () => { + // Rows start at 120px, not the 100px estimate: resizeItem caches only a + // non-zero delta, so an estimate-sized row would never be cached. + const { h, rows } = deferredHarness({ measurer: measureRowLive, scrollTop: 9880, indexes: [100] }); + const row = rows.get(100); + const before = row.reads; + h.virtualizer.elementsCache.delete("row-100"); + h.virtualizer.measureElement(row.node); + assert.equal(row.reads - before, 0); +}); + +// Forced layouts = layout reads that follow a DOM write (the host's direct DOM +// updates write the sizer and transforms inside onChange; scrollToFn writes +// scrollTop), on the unbatched end-pinned flush path. +function countForcedLayouts(measurer) { + const { h, rows, deliver } = deferredHarness({ + measurer, + scrollTop: 19400, + indexes: [195, 196, 197, 198], + harnessOptions: { anchorTo: "end" }, + }); + h.emitScroll(h.maxScrollOffset(), false); + h.runRafs(); + assert.ok(h.virtualizer.isAtEnd(), "end-pinned (unbatched) path"); + let dirty = false; + let layouts = 0; + const read = () => { + if (dirty) { + layouts += 1; + dirty = false; + } + }; + for (const row of rows.values()) { + const prev = Object.getOwnPropertyDescriptor(row.node, "offsetHeight").get; + Object.defineProperty(row.node, "offsetHeight", { + get() { + read(); + return prev.call(this); + }, + }); + } + Object.defineProperty(h.element, "scrollHeight", { + get() { + read(); + return h.domSizerHeight; + }, + }); + const options = h.virtualizer.options; + h.virtualizer.setOptions({ + ...options, + onChange: (...args) => { + options.onChange(...args); + dirty = true; + }, + scrollToFn: (...args) => { + options.scrollToFn(...args); + dirty = true; + }, + }); + const sizes = [ + [195, 140], + [196, 150], + [197, 160], + [198, 170], + ]; + for (const [index, size] of sizes) rows.get(index).live = size; + deliver(sizes); + dirty = false; + h.runRafs(); + return { layouts, realScrollTop: h.realScrollTop, sizer: h.domSizerHeight }; +} + +test("live reads add no forced layouts on the end-pinned flush", () => { + assert.deepEqual(countForcedLayouts(measureRowLive), countForcedLayouts(core.measureElement)); +});