From ccba48375ca4cbd76ad0d85cd05bf362ceff398d Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 14 Sep 2026 22:26:14 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(virtual):=20=E6=9C=AB=E7=AB=AF=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E6=8C=89=E7=9C=9F=E5=AE=9E=E6=BB=9A=E5=8A=A8=20clamp?= =?UTF-8?q?=20=E5=88=A4=E5=AE=9A=EF=BC=8C=E8=84=B1=E7=A6=BB=E6=80=81?= =?UTF-8?q?=E8=AF=BB=E8=80=85=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=A2=9E=E9=95=BF=E6=8B=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resizeItem 的 wasAtEnd 此前用 getVirtualDistanceFromEnd()(列表局部坐标)判 "在末端"。宿主在虚拟列表下方还渲染了 18px shell padding 与 194px 底部占位, 列表末端离真实 clamp 约 212px,于是脱离跟随、停在距底 220px 内的读者都被 当成"在末端",任何底部行尺寸变化都经 applyScrollAdjustment 直写 scrollTop: AskUserQuestion 卡片到达视觉位移 305px,审批请求 63px,末行流式增高 302px。 改用 getDistanceFromEnd()(DOM scrollHeight 口径,与 _willUpdate 里 followOnAppend 的 isAtEnd 一致)并加 scrollElement 判空;删除不再使用的私有 getVirtualDistanceFromEnd,types 同步并为 scrollEndThreshold 补口径说明。 测试:harness 新增 extraScrollHeight / maxScrollOffset 模拟列表下方的非虚拟 高度;end-anchor-distance 4 例覆盖带内不写、带边不写、真实 clamp 仍钉底、 默认 harness 语义不变。 Co-authored-by: Claude Fable 5.1 --- crates/virtual-core/src/index.ts | 18 +++-- .../test/end-anchor-distance.test.mjs | 77 +++++++++++++++++++ crates/virtual-core/test/helpers/harness.mjs | 13 +++- crates/virtual-core/types/index.d.ts | 7 +- 4 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 crates/virtual-core/test/end-anchor-distance.test.mjs diff --git a/crates/virtual-core/src/index.ts b/crates/virtual-core/src/index.ts index b5b765351..ad208bbab 100644 --- a/crates/virtual-core/src/index.ts +++ b/crates/virtual-core/src/index.ts @@ -1798,10 +1798,19 @@ export class Virtualizer< const delta = size - itemSize if (delta !== 0) { + // "At the end" is measured against the real scroll clamp + // (getDistanceFromEnd → DOM scrollHeight), the same coordinate the + // followOnAppend check in _willUpdate reads. The virtual list's own end + // can sit well above that clamp — this repo's transcripts render a + // fixed spacer below the sizer — and the virtual-distance check treated + // the whole band under it as "at end": a detached reader parked there + // was dragged along by every bottom-row growth (300px+ visible jumps + // when a question card or an approval chip arrived). const wasAtEnd = this.options.anchorTo === 'end' && this.scrollState?.behavior !== 'smooth' && - this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + this.scrollElement !== null && + this.getDistanceFromEnd() <= this.options.scrollEndThreshold const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 const shouldAdjustScroll = this.scrollState?.behavior !== 'smooth' && @@ -2062,13 +2071,6 @@ export class Virtualizer< } } - private getVirtualDistanceFromEnd = () => { - return Math.max( - this.getTotalSize() - this.getSize() - this.getScrollOffset(), - 0, - ) - } - getDistanceFromEnd = () => { return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0) } diff --git a/crates/virtual-core/test/end-anchor-distance.test.mjs b/crates/virtual-core/test/end-anchor-distance.test.mjs new file mode 100644 index 000000000..5bd398fe0 --- /dev/null +++ b/crates/virtual-core/test/end-anchor-distance.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHarness } from "./helpers/harness.mjs"; + +// `anchorTo: 'end'` pins the viewport to the end while a resize lands, but +// only when the viewport really is at the end. That distance must be measured +// against the browser's scroll clamp (DOM scrollHeight), not the virtual +// list's own end: the hosts render a fixed spacer (composer reserve) and +// shell padding below the sizer, so the list ends ~200px above the clamp. The +// old virtual-distance check treated that whole band as "at end" and dragged a +// detached reader down by every bottom-row growth. + +const VIEWPORT = 600; +const ESTIMATE = 100; +// 18px shell padding-top + 194px bottom spacer in the WebUI transcript. +const NON_VIRTUAL_BOTTOM = 212; +const LAST_INDEX = 199; + +function parkedHarness(gapFromClampPx) { + const h = createHarness({ + scrollAnchoring: "origin", + anchorTo: "end", + viewport: VIEWPORT, + estimate: ESTIMATE, + extraScrollHeight: NON_VIRTUAL_BOTTOM, + initialOffset: 0, + }); + const offset = h.maxScrollOffset() - gapFromClampPx; + h.emitScroll(offset, true); + h.emitScroll(offset, false); + h.runRafs(); + h.writes.length = 0; + return h; +} + +test("a reader parked inside the non-virtual bottom band is not end-anchored", () => { + // 100px above the clamp = 112px *below* the virtual list's end: the old + // check clamped this to a virtual distance of 0 and pinned. + const h = parkedHarness(100); + const before = h.realScrollTop; + + h.virtualizer.resizeItem(LAST_INDEX, ESTIMATE + 400); + h.runRafs(); + + assert.equal(h.writes.length, 0, "growth below the reader must not write scrollTop"); + assert.equal(h.realScrollTop, before); + assert.equal(h.virtualizer.scrollOffset, before); +}); + +test("a reader just above the band edge is not end-anchored either", () => { + // Virtual distance 8px (the old threshold hit exactly) is still 220px from + // the real clamp. + const h = parkedHarness(NON_VIRTUAL_BOTTOM + 8); + const before = h.realScrollTop; + + h.virtualizer.resizeItem(LAST_INDEX, ESTIMATE + 400); + h.runRafs(); + + assert.equal(h.writes.length, 0); + assert.equal(h.realScrollTop, before); +}); + +test("a reader at the real clamp keeps the end pinned when the bottom row grows", () => { + const h = parkedHarness(0); + const before = h.realScrollTop; + + h.virtualizer.resizeItem(LAST_INDEX, ESTIMATE + 400); + + assert.equal(h.writes.length, 1, "end anchoring writes once for the growth"); + assert.equal(h.writes[0].target, before + 400); +}); + +test("the default harness (no non-virtual height) keeps its clamp semantics", () => { + const h = createHarness({ anchorTo: "end", initialOffset: 0 }); + assert.equal(h.maxScrollOffset(), 200 * 100 - 600); + assert.equal(h.element.scrollHeight, 200 * 100); +}); diff --git a/crates/virtual-core/test/helpers/harness.mjs b/crates/virtual-core/test/helpers/harness.mjs index fce60c4db..2469ba7ba 100644 --- a/crates/virtual-core/test/helpers/harness.mjs +++ b/crates/virtual-core/test/helpers/harness.mjs @@ -15,6 +15,10 @@ export function createHarness(options = {}) { directionalOverscanPx, overscan = 0, scrollEndThreshold = 8, + // Non-virtual height below the sizer (a host's bottom spacer and shell + // padding): part of DOM scrollHeight, invisible to getTotalSize(). The + // hosts render ~200px of it under the transcript list. + extraScrollHeight = 0, } = options; const core = loadVirtualCore(); @@ -57,7 +61,7 @@ export function createHarness(options = {}) { return state.realScrollTop; }, get scrollHeight() { - return state.domSizerHeight; + return state.domSizerHeight + extraScrollHeight; }, clientHeight: viewport, }; @@ -77,7 +81,7 @@ export function createHarness(options = {}) { state.writes.push({ target, swallowed: true }); return; } - const max = Math.max(0, state.domSizerHeight - viewport); + const max = Math.max(0, state.domSizerHeight + extraScrollHeight - viewport); state.realScrollTop = Math.max(0, Math.min(max, target)); state.writes.push({ target, swallowed: false, landed: state.realScrollTop }); }, @@ -116,6 +120,11 @@ export function createHarness(options = {}) { get domSizerHeight() { return state.domSizerHeight; }, + // The browser's real scroll clamp: sizer plus any non-virtual height below + // it, minus the viewport. + maxScrollOffset() { + return Math.max(0, state.domSizerHeight + extraScrollHeight - viewport); + }, setSwallowWrites(value) { state.swallowWrites = value; }, diff --git a/crates/virtual-core/types/index.d.ts b/crates/virtual-core/types/index.d.ts index f0fdf0576..fba53f0dd 100644 --- a/crates/virtual-core/types/index.d.ts +++ b/crates/virtual-core/types/index.d.ts @@ -98,6 +98,12 @@ export interface VirtualizerOptions VirtualItem | undefined; private getMaxScrollOffset; - private getVirtualDistanceFromEnd; getDistanceFromEnd: () => number; isAtEnd: (threshold?: number) => boolean; /** From 2b099f59b3eb01baedcf1c959baa7df93f6b616b Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 14 Sep 2026 22:26:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(webui):=20=E5=AF=B9=E8=AF=9D=E5=8C=BA?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=9B=9E=E8=B4=B4=E5=8C=BA=E5=90=B8=E9=99=84?= =?UTF-8?q?=EF=BC=8C=E8=83=8C=E6=99=AF=20Pane=20=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=BB=9A=E5=8A=A8=E8=B7=9F=E9=9A=8F=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主转写区的 useScrollFollow 沿用默认 192px 回贴区:滚轮下行进入该区间的那一 tick 直接 pin 到底,读者看到正文突然上跳(复现页实测单 tick 231px)。改为 reattachZonePx: 0,只在真正到达底部(8px 容差)时恢复跟随;到达底部后向下 滚轮、手势落底、指针在底部释放仍会重新贴底。 工作台背景 Pane 原本手写一套贴底:距底不足 48px 即视为跟随,行数或 revision 一变就写 scrollTop,读者停在底部附近会被每次流式增量吸回,且与主 Pane、 桌面端各 Pane 的引擎语义不一致。换成同一 useScrollFollow(zone 0,会话切换 stickToBottom),ScrollArea 根与视口经 callback ref 接入,删除三处 scrollTop 写入。 新增 transcript-scroll-follow-contract 源码契约测试锁住两处配置。 Co-authored-by: Claude Fable 5.1 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 5 ++ .../workbench/GatewayConversationPaneHost.tsx | 70 ++++++++----------- ...transcript-scroll-follow-contract.test.mjs | 38 ++++++++++ 3 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 crates/agent-gateway/web/test/transcript-scroll-follow-contract.test.mjs diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 096ab2b49..93678764b 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -246,6 +246,11 @@ function useGatewayAppController() { viewport: transcriptViewport, listenerRoot: transcriptScrollAreaRoot, trackKeys: true, + // 回贴区为 0:只有真正到达底部(8px 容差内)才恢复跟随。默认的 192px + // 回贴区会在滚轮下行进入该区间的那一 tick 直接 pin 到底,读者看到的是 + // 正文突然上跳一段(复现页实测单 tick 231px),即"磁吸"。到达底部后向下 + // 滚轮、手势落底、指针在底部释放三种路径仍会重新贴底。 + config: { reattachZonePx: 0 }, }); // 楼层导航:当前楼层由转写区上报,跳转经 navRef 直达虚拟列表;粘底跟随 // 激活时程序化滚动会被立即拽回底部——跳转前先按「跳入历史」语义解除跟随。 diff --git a/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx b/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx index b935c2eed..55acc4a31 100644 --- a/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx +++ b/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx @@ -39,6 +39,7 @@ import { mergePendingUploadedFiles, type PendingUploadedFile, } from "@liveagent/ui/lib/chat/uploadedFiles"; +import { useScrollFollow } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; import { toTrajectoryMessages } from "@liveagent/ui/lib/trajectory/transcriptMessages"; import { ChatComposerBar, @@ -616,41 +617,27 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr if (composerRef.current) pageComposerRef.current = composerRef.current; }); - // ---- 转录滚动跟随:贴底自动跟进,用户上滚即释放,支持一键回底 ------------ - const viewportRef = useRef(null); - const followingRef = useRef(true); - const [following, setFollowing] = useState(true); - const detachScrollRef = useRef<(() => void) | null>(null); - const setViewport = useCallback((element: HTMLDivElement | null) => { - detachScrollRef.current?.(); - detachScrollRef.current = null; - viewportRef.current = element; - if (!element) return; - element.scrollTop = element.scrollHeight; - const handleScroll = () => { - const nearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 48; - followingRef.current = nearBottom; - setFollowing(nearBottom); - }; - element.addEventListener("scroll", handleScroll, { passive: true }); - detachScrollRef.current = () => element.removeEventListener("scroll", handleScroll); - }, []); - useEffect(() => () => detachScrollRef.current?.(), []); + // ---- 转录滚动跟随 ----------------------------------------------------- + // 背景 Pane 与主 Pane 共用同一套滚动跟随引擎:主 Pane 的视口由 GatewayApp + // 持有的引擎接管(经 primary.setTranscriptViewport 接线),本地这套只在 + // 非主态生效。回贴区为 0:只有真正到达底部才恢复跟随。此前这里手写了一套 + // "距底不足 48px 即视为跟随、行数一变就写 scrollTop" 的逻辑,读者停在底部 + // 附近时会被每次流式增量吸回底部,而且与桌面端各 Pane 的引擎语义不一致。 + const usePrimary = Boolean(isPrimary && primary); + const [paneScrollAreaRoot, setPaneScrollAreaRoot] = useState(null); + const [paneViewport, setPaneViewport] = useState(null); + const { handle: paneFollow, following: paneFollowing } = useScrollFollow({ + viewport: paneViewport, + listenerRoot: paneScrollAreaRoot, + enabled: !usePrimary, + config: { reattachZonePx: 0 }, + }); + // 会话切换后落在最新消息上,与桌面端 ChatTranscript 一致。 + // biome-ignore lint/correctness/useExhaustiveDependencies: conversationId 是有意的重置信号,动作由 handle 执行。 + useLayoutEffect(() => { + if (!usePrimary) paneFollow.stickToBottom(); + }, [conversationId, paneFollow, usePrimary]); const rowCount = transcript.rows.length; - // biome-ignore lint/correctness/useExhaustiveDependencies: 行数/修订变化时按跟随态贴底,效果体不直接读取它们。 - useEffect(() => { - const viewport = viewportRef.current; - if (!viewport || !followingRef.current) return; - viewport.scrollTop = viewport.scrollHeight; - }, [rowCount, transcript.revision]); - const jumpToBottom = useCallback(() => { - const viewport = viewportRef.current; - if (!viewport) return; - viewport.scrollTop = viewport.scrollHeight; - followingRef.current = true; - setFollowing(true); - }, []); - const isViewportFollowing = useCallback(() => followingRef.current, []); // ---- 每会话模型/用量/进度/审批 ------------------------------------------- const selectedValue = selection @@ -738,12 +725,15 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr ); } - const usePrimary = Boolean(isPrimary && primary); - const transcriptFollowing = usePrimary ? (primary?.viewportFollowing ?? following) : following; + const transcriptFollowing = usePrimary + ? (primary?.viewportFollowing ?? paneFollowing) + : paneFollowing; const transcriptIsViewportFollowing = - usePrimary && primary?.isViewportFollowing ? primary.isViewportFollowing : isViewportFollowing; + usePrimary && primary?.isViewportFollowing + ? primary.isViewportFollowing + : paneFollow.isFollowing; const handleJumpToBottom = - usePrimary && primary?.onJumpToBottom ? primary.onJumpToBottom : jumpToBottom; + usePrimary && primary?.onJumpToBottom ? primary.onJumpToBottom : paneFollow.jumpToBottom; const transcriptTree = ( diff --git a/crates/agent-gateway/web/test/transcript-scroll-follow-contract.test.mjs b/crates/agent-gateway/web/test/transcript-scroll-follow-contract.test.mjs new file mode 100644 index 000000000..8c6e85ed8 --- /dev/null +++ b/crates/agent-gateway/web/test/transcript-scroll-follow-contract.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const gatewayAppSource = fs.readFileSync( + new URL("../src/app/GatewayApp.tsx", import.meta.url), + "utf8", +); +const paneHostSource = fs.readFileSync( + new URL("../src/app/workbench/GatewayConversationPaneHost.tsx", import.meta.url), + "utf8", +); + +// Both transcript surfaces attach to the bottom only at the physical clamp. +// The engine's default 192px reattach zone pinned the viewport on the first +// wheel tick that landed inside it: a 231px visible jump measured with +// test/browser/scroll-follow-probe.html, perceived as the page "snapping" +// while scrolling toward the bottom. +test("the primary transcript follow engine runs without a reattach zone", () => { + const call = gatewayAppSource.match(/useScrollFollow\(\{[\s\S]*?\}\);/); + assert.ok(call, "GatewayApp wires the transcript follow engine"); + assert.match(call[0], /viewport:\s*transcriptViewport/); + assert.match(call[0], /reattachZonePx:\s*0\b/); +}); + +test("background panes share the follow engine instead of a hand-rolled 48px snap", () => { + assert.match( + paneHostSource, + /import \{ useScrollFollow \} from "@liveagent\/ui\/lib\/chat-scroll\/useScrollFollow";/, + ); + const call = paneHostSource.match(/useScrollFollow\(\{[\s\S]*?\}\);/); + assert.ok(call, "the pane host wires the shared engine for non-primary panes"); + assert.match(call[0], /reattachZonePx:\s*0\b/); + // The old implementation re-attached on position alone (gap < 48px) and + // wrote scrollTop on every row/revision change while "following". + assert.doesNotMatch(paneHostSource, /scrollTop\s*=/); + assert.doesNotMatch(paneHostSource, /nearBottom/); +}); From c0f78be0631336eee00323ef22b3c7c6bb01cbda Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 14 Sep 2026 22:26:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(webui):=20=E8=BD=AC=E5=86=99=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E8=B7=9F=E9=9A=8F=E7=9A=84=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=E9=A1=B5=E4=B8=8E=20CDP=20=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scroll-follow-probe.html 挂真实 ScrollArea + GatewayTranscript + useScrollFollow, 拦截 scrollTop/scrollTo 写入并按调用栈归因(follow-engine / virtualizer / base-ui),用视口顶部那一行的屏幕位移衡量肉眼可见的跳动;?zone= 试回贴区, ?ask=1|2 与 ?approval=1|2 构造挂起的询问卡片与审批请求。 scroll-follow-probe.cdp.mjs 用 Node 内建 WebSocket 直连无头 Chromium 的 CDP, 逐 tick 发滚轮事件记录每步位移与写入方;scroll-follow-probe.eval.mjs 对已 打开的页面即时求值。 Co-authored-by: Claude Fable 5.1 --- .../test/browser/scroll-follow-probe.cdp.mjs | 617 ++++++++++++++++++ .../test/browser/scroll-follow-probe.eval.mjs | 62 ++ .../web/test/browser/scroll-follow-probe.html | 555 ++++++++++++++++ 3 files changed, 1234 insertions(+) create mode 100644 crates/agent-gateway/web/test/browser/scroll-follow-probe.cdp.mjs create mode 100644 crates/agent-gateway/web/test/browser/scroll-follow-probe.eval.mjs create mode 100644 crates/agent-gateway/web/test/browser/scroll-follow-probe.html diff --git a/crates/agent-gateway/web/test/browser/scroll-follow-probe.cdp.mjs b/crates/agent-gateway/web/test/browser/scroll-follow-probe.cdp.mjs new file mode 100644 index 000000000..361141555 --- /dev/null +++ b/crates/agent-gateway/web/test/browser/scroll-follow-probe.cdp.mjs @@ -0,0 +1,617 @@ +// Throwaway CDP driver for scroll-follow-probe.html. Drives a headless +// Chromium (Edge) already listening on CDP_PORT, replays wheel gestures on the +// real WebUI transcript stack and records every programmatic scroll write with +// its owner, so a visible jump can be attributed to a concrete writer. +// +// node test/browser/scroll-follow-probe.cdp.mjs [wheel|growth|all] +// +// Env: CDP_PORT (9333), PROBE_URL, PROBE_OUT (.codex-artifacts/scroll-probe) +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const PORT = Number(process.env.CDP_PORT ?? 9333); +const PAGE_URL = + process.env.PROBE_URL ?? "http://127.0.0.1:5173/test/browser/scroll-follow-probe.html"; +const OUT_DIR = process.env.PROBE_OUT ?? join(process.cwd(), ".codex-artifacts", "scroll-probe"); +const SCENARIO = process.argv[2] ?? "all"; +const WHEEL_STEP = Number(process.env.PROBE_WHEEL_STEP ?? 100); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function getPageTarget() { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${PORT}/json/list`); + const targets = await response.json(); + const page = targets.find((target) => target.type === "page"); + if (page) return page; + } catch {} + await sleep(250); + } + throw new Error(`no CDP page target on port ${PORT}`); +} + +class Cdp { + constructor(ws) { + this.ws = ws; + this.nextId = 0; + this.pending = new Map(); + this.listeners = new Map(); + this.consoleLog = []; + ws.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + return; + } + if (message.method) { + for (const listener of this.listeners.get(message.method) ?? []) listener(message.params); + } + }); + } + send(method, params = {}) { + const id = ++this.nextId; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); + } + on(method, listener) { + if (!this.listeners.has(method)) this.listeners.set(method, []); + this.listeners.get(method).push(listener); + } + async eval(expression) { + const result = await this.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw new Error(`eval failed: ${JSON.stringify(result.exceptionDetails).slice(0, 800)}`); + } + return result.result.value; + } +} + +async function connect() { + const target = await getPageTarget(); + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, { once: true }); + ws.addEventListener("error", reject, { once: true }); + }); + const cdp = new Cdp(ws); + await cdp.send("Page.enable"); + await cdp.send("Runtime.enable"); + cdp.on("Runtime.consoleAPICalled", (params) => { + if (params.type === "error" || params.type === "warning") { + cdp.consoleLog.push({ + type: params.type, + text: params.args + .map((arg) => arg.value ?? arg.description ?? "") + .join(" ") + .slice(0, 400), + }); + } + }); + cdp.on("Runtime.exceptionThrown", (params) => { + cdp.consoleLog.push({ + type: "exception", + text: ( + params.exceptionDetails.exception?.description ?? + params.exceptionDetails.text ?? + "" + ).slice(0, 600), + }); + }); + return cdp; +} + +async function loadPage(cdp, url) { + const loaded = new Promise((resolve) => cdp.on("Page.loadEventFired", resolve)); + await cdp.send("Page.navigate", { url }); + await loaded; + const started = Date.now(); + while (Date.now() - started < 240_000) { + const ready = await cdp + .eval("Boolean(window.__probe && window.__probe.ready)") + .catch(() => false); + if (ready) return; + await sleep(500); + } + throw new Error("probe page never became ready (check the Vite log / console errors)"); +} + +const metrics = (cdp) => cdp.eval("window.__probe.metrics()"); +const writesSince = (cdp, from) => cdp.eval(`window.__probe.writes.slice(${from})`); +const writeCount = (cdp) => cdp.eval("window.__probe.writes.length"); + +async function wheel(cdp, x, y, deltaY) { + await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: 0, deltaY }); +} + +async function center(cdp) { + const rect = await cdp.eval("window.__probe.viewportRect()"); + return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) }; +} + +function brief(write) { + return { + kind: write.kind, + who: write.who, + from: write.from, + to: write.to, + t: write.t, + top: write.top, + }; +} + +// On-screen movement of the row under the reader's eyes (negative = content +// moved up). This is the number the user perceives; scrollTop deltas alone +// cannot tell a real jump from an origin rebase that moves no pixels. +const anchorBefore = (cdp) => cdp.eval("window.__probe.visibleAnchor()"); +async function visualDelta(cdp, anchor) { + if (!anchor) return null; + const top = await cdp.eval(`window.__probe.anchorTop(${JSON.stringify(anchor.key)})`); + return top === null ? null : Math.round((top - anchor.top) * 10) / 10; +} + +// Scenario 1: detach with wheel-up, then wheel back down to the bottom one +// step at a time. Any step that moves further than the wheel delta is a jump. +async function scenarioWheelToBottom(cdp) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(300); + const { x, y } = await center(cdp); + const start = await metrics(cdp); + for (let i = 0; i < 14; i += 1) { + await wheel(cdp, x, y, -120); + await sleep(60); + } + await sleep(800); + const detached = await metrics(cdp); + const steps = []; + let clampedSteps = 0; + for (let i = 0; i < 120; i += 1) { + const before = await metrics(cdp); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + await wheel(cdp, x, y, WHEEL_STEP); + await sleep(160); + const after = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const writes = await writesSince(cdp, from); + const delta = Math.round((after.scrollTop - before.scrollTop) * 10) / 10; + steps.push({ + i, + before: { scrollTop: before.scrollTop, gap: before.gap, following: before.following }, + after: { scrollTop: after.scrollTop, gap: after.gap, following: after.following }, + delta, + visual, + jump: delta > WHEEL_STEP + 4, + writes: writes.map(brief), + }); + if (after.gap <= 1) clampedSteps += 1; + else clampedSteps = 0; + if (clampedSteps >= 2) break; + } + return { start, detached, steps, jumps: steps.filter((step) => step.jump) }; +} + +// Scenario 2: detached reader parked `gapPx` above the clamp (latch expired), +// then the last row grows. A moving scrollTop means some writer dragged the +// reader along. +async function scenarioDetachedGrowth(cdp, gapPx) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(300); + const { x, y } = await center(cdp); + for (let i = 0; i < 5; i += 1) { + await wheel(cdp, x, y, -120); + await sleep(60); + } + await sleep(800); + const afterDetach = await metrics(cdp); + await cdp.eval(`window.__probe.setGap(${gapPx})`); + await sleep(700); + const positioned = await metrics(cdp); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + await cdp.eval("window.__probe.growLastRow(900)"); + await sleep(800); + const grown = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const writes = await writesSince(cdp, from); + return { + gapPx, + afterDetach, + positioned, + grown, + visual, + moved: Math.round((grown.scrollTop - positioned.scrollTop) * 10) / 10, + scrollHeightDelta: grown.scrollHeight - positioned.scrollHeight, + writes: writes.map(brief), + }; +} + +// Scenario 3: detached reader parked `gapPx` above the clamp, then a whole new +// user+assistant turn arrives (count change + first measurement of new rows). +async function scenarioAppendTurn(cdp, gapPx) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(300); + const { x, y } = await center(cdp); + for (let i = 0; i < 5; i += 1) { + await wheel(cdp, x, y, -120); + await sleep(60); + } + await sleep(800); + const afterDetach = await metrics(cdp); + await cdp.eval(`window.__probe.setGap(${gapPx})`); + await sleep(700); + const positioned = await metrics(cdp); + const from = await writeCount(cdp); + const appendedKeys = await cdp.eval("window.__probe.appendTurn()"); + await sleep(900); + const grown = await metrics(cdp); + const writes = await writesSince(cdp, from); + return { + gapPx, + appendedKeys, + afterDetach, + positioned, + grown, + moved: Math.round((grown.scrollTop - positioned.scrollTop) * 10) / 10, + scrollHeightDelta: grown.scrollHeight - positioned.scrollHeight, + writes: writes.map(brief), + }; +} + +const pick = (m) => ({ + scrollTop: m.scrollTop, + gap: m.gap, + following: m.following, + lastRowHeight: m.lastRowHeight, +}); +const r1 = (value) => Math.round(value * 10) / 10; + +function diffHeights(before, after) { + const changed = []; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + const a = before[key]; + const b = after[key]; + if (a === undefined || b === undefined) { + changed.push(`${key}:${a ?? "unmounted"}->${b ?? "unmounted"}`); + } else if (Math.abs(a - b) > 0.5) { + changed.push(`${key}:${a}->${b}`); + } + } + return changed; +} + +// Ask-mode scenario A: pinned at the bottom of a live turn parked on a pending +// AskUserQuestion card, wheel up 60px at a time and dwell 1.3s per step so the +// per-second timers (elapsed label, countdown) tick while the reader is inside +// the bottom band. A step whose scrollTop moved by anything other than the +// wheel delta had a programmatic writer. +async function scenarioAskScrollUp(cdp) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(600); + const { x, y } = await center(cdp); + const start = await metrics(cdp); + const steps = []; + for (let i = 0; i < 14; i += 1) { + const before = await metrics(cdp); + const heightsBefore = await cdp.eval("window.__probe.rowHeights()"); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + await wheel(cdp, x, y, -60); + await sleep(1300); + const after = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const heightsAfter = await cdp.eval("window.__probe.rowHeights()"); + const writes = await writesSince(cdp, from); + const delta = r1(after.scrollTop - before.scrollTop); + steps.push({ + i, + before: pick(before), + after: pick(after), + delta, + visual, + unexpected: Math.abs(delta + 60) > 4, + writes: writes.map(brief), + rowChanges: diffHeights(heightsBefore, heightsAfter), + }); + } + return { start: pick(start), steps, unexpected: steps.filter((step) => step.unexpected) }; +} + +// Arrival scenario (?ask=2): the live turn shows prose only; park the reader +// (detached) `gapPx` above the clamp, then the AskUserQuestion tool_call +// arrives and the card mounts. `gapPx === null` keeps the reader following at +// the bottom (the expected pin case). +async function scenarioAskArrive(cdp, gapPx) { + // Fresh page per run: arriveAsk() is one-shot (the tool_call stays in the + // rows), so each sub-scenario must start from the prose-only live turn. + await loadPage(cdp, PAGE_URL); + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(500); + const { x, y } = await center(cdp); + if (gapPx !== null) { + for (let i = 0; i < 5; i += 1) { + await wheel(cdp, x, y, -120); + await sleep(60); + } + await sleep(800); + await cdp.eval(`window.__probe.setGap(${gapPx})`); + await sleep(500); + } + const positioned = await metrics(cdp); + const heightsBefore = await cdp.eval("window.__probe.rowHeights()"); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + await cdp.eval("window.__probe.arriveAsk()"); + const samples = []; + for (let i = 0; i < 6; i += 1) { + await sleep(150); + samples.push(pick(await metrics(cdp))); + } + await sleep(600); + const settled = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const heightsAfter = await cdp.eval("window.__probe.rowHeights()"); + const writes = await writesSince(cdp, from); + return { + gapPx, + positioned: pick(positioned), + samples, + settled: pick(settled), + visual, + moved: r1(settled.scrollTop - positioned.scrollTop), + scrollHeightDelta: settled.scrollHeight - positioned.scrollHeight, + writes: writes.map(brief), + rowChanges: diffHeights(heightsBefore, heightsAfter), + }; +} + +// Ask-mode scenario B: from the detached position, wheel back down toward the +// card 60px at a time (300ms dwell) until clamped. +async function scenarioAskScrollDown(cdp) { + const { x, y } = await center(cdp); + const state = await metrics(cdp); + if (state.following || state.gap < 600) { + for (let i = 0; i < 14; i += 1) { + await wheel(cdp, x, y, -60); + await sleep(60); + } + await sleep(800); + } + const steps = []; + let clamped = 0; + for (let i = 0; i < 60; i += 1) { + const before = await metrics(cdp); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + await wheel(cdp, x, y, 60); + await sleep(300); + const after = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const writes = await writesSince(cdp, from); + const delta = r1(after.scrollTop - before.scrollTop); + steps.push({ + i, + before: pick(before), + after: pick(after), + delta, + visual, + jump: delta > 64, + writes: writes.map(brief), + }); + if (after.gap <= 1) clamped += 1; + else clamped = 0; + if (clamped >= 2) break; + } + return { steps, jumps: steps.filter((step) => step.jump) }; +} + +// Ask-mode scenario C: park detached `gapPx` above the clamp and do nothing +// for 4s; any movement comes from timers/animations, not from input. +async function scenarioAskHold(cdp, gapPx) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(400); + const { x, y } = await center(cdp); + for (let i = 0; i < 5; i += 1) { + await wheel(cdp, x, y, -120); + await sleep(60); + } + await sleep(800); + await cdp.eval(`window.__probe.setGap(${gapPx})`); + await sleep(500); + const positioned = await metrics(cdp); + const from = await writeCount(cdp); + const samples = []; + for (let i = 0; i < 8; i += 1) { + await sleep(500); + samples.push(pick(await metrics(cdp))); + } + const writes = await writesSince(cdp, from); + return { + gapPx, + positioned: pick(positioned), + samples, + moved: r1(samples[samples.length - 1].scrollTop - positioned.scrollTop), + writes: writes.map(brief), + }; +} + +// Approval variant (?approval=2): the pending tool_call that arrives carries +// the approval markers; afterwards the composer grows by `barPx` the way the +// real approval bar does, and the bottom spacer follows. +async function scenarioApprovalArrive(cdp, gapPx, barPx = 72) { + const arrival = await scenarioAskArrive(cdp, gapPx); + const positioned = await metrics(cdp); + const anchor = await anchorBefore(cdp); + const from = await writeCount(cdp); + const composer = await cdp.eval(`window.__probe.growComposer(${barPx})`); + await sleep(700); + const settled = await metrics(cdp); + const visual = await visualDelta(cdp, anchor); + const writes = await writesSince(cdp, from); + return { + ...arrival, + composerGrowth: { + barPx, + composer, + positioned: pick(positioned), + settled: pick(settled), + visual, + moved: r1(settled.scrollTop - positioned.scrollTop), + scrollHeightDelta: settled.scrollHeight - positioned.scrollHeight, + writes: writes.map(brief), + }, + }; +} + +// Diagnostic: does growLastRow actually grow the mounted row and the sizer? +async function scenarioGrowthDiag(cdp) { + await cdp.eval("window.__probe.reset()"); + await cdp.eval("window.__probe.stickToBottom()"); + await sleep(300); + const before = await cdp.eval("window.__probe.lastRowDiag()"); + await cdp.eval("window.__probe.growLastRow(900)"); + await sleep(900); + const after = await cdp.eval("window.__probe.lastRowDiag()"); + const writes = await writesSince(cdp, 0); + return { before, after, writes: writes.map(brief) }; +} + +function formatWrites(writes) { + return writes.map((w) => `${w.who}:${w.kind}(${w.from}->${w.to})`).join(" ") || "none"; +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + const cdp = await connect(); + await loadPage(cdp, PAGE_URL); + const info = await cdp.eval("window.__probe.info()"); + const results = { url: PAGE_URL, info, scenarios: {} }; + if (SCENARIO === "diag") { + results.scenarios.growthDiag = await scenarioGrowthDiag(cdp); + console.log(JSON.stringify(results.scenarios.growthDiag, null, 2)); + } + if (SCENARIO === "approve") { + results.scenarios.approveGap120 = await scenarioApprovalArrive(cdp, 120); + results.scenarios.approveGap400 = await scenarioApprovalArrive(cdp, 400); + results.scenarios.approveFollowing = await scenarioApprovalArrive(cdp, null); + } + if (SCENARIO === "arrive") { + results.scenarios.arriveGap120 = await scenarioAskArrive(cdp, 120); + results.scenarios.arriveGap400 = await scenarioAskArrive(cdp, 400); + results.scenarios.arriveFollowing = await scenarioAskArrive(cdp, null); + } + if (SCENARIO === "ask") { + results.scenarios.askScrollUp = await scenarioAskScrollUp(cdp); + results.scenarios.askScrollDown = await scenarioAskScrollDown(cdp); + results.scenarios.askHold120 = await scenarioAskHold(cdp, 120); + results.scenarios.askHold400 = await scenarioAskHold(cdp, 400); + } + if (SCENARIO === "wheel" || SCENARIO === "all") { + results.scenarios.wheelToBottom = await scenarioWheelToBottom(cdp); + } + if (SCENARIO === "growth" || SCENARIO === "all") { + results.scenarios.growthGap100 = await scenarioDetachedGrowth(cdp, 100); + results.scenarios.growthGap400 = await scenarioDetachedGrowth(cdp, 400); + } + if (SCENARIO === "append" || SCENARIO === "all") { + results.scenarios.appendGap100 = await scenarioAppendTurn(cdp, 100); + results.scenarios.appendGap400 = await scenarioAppendTurn(cdp, 400); + } + results.console = cdp.consoleLog; + const outPath = join(OUT_DIR, `result-${SCENARIO}-${Date.now()}.json`); + writeFileSync(outPath, JSON.stringify(results, null, 2)); + + console.log(`probe info: ${JSON.stringify(info)}`); + const wheelResult = results.scenarios.wheelToBottom; + if (wheelResult) { + console.log( + `wheel: start gap=${wheelResult.start.gap} following=${wheelResult.start.following}; detached gap=${wheelResult.detached.gap} following=${wheelResult.detached.following}; steps=${wheelResult.steps.length}`, + ); + for (const step of wheelResult.steps) { + const tag = step.jump ? "JUMP" : " "; + console.log( + `${tag} #${String(step.i).padStart(3)} gap ${String(step.before.gap).padStart(6)} -> ${String(step.after.gap).padStart(6)} delta ${String(step.delta).padStart(7)} visual ${String(step.visual).padStart(7)} following ${step.before.following}->${step.after.following} ${formatWrites(step.writes)}`, + ); + } + } + const askUp = results.scenarios.askScrollUp; + if (askUp) { + console.log( + `askScrollUp: start gap=${askUp.start.gap} following=${askUp.start.following} lastRowHeight=${askUp.start.lastRowHeight}`, + ); + for (const step of askUp.steps) { + const tag = step.unexpected ? "MOVE" : " "; + console.log( + `${tag} #${String(step.i).padStart(3)} gap ${String(step.before.gap).padStart(6)} -> ${String(step.after.gap).padStart(6)} delta ${String(step.delta).padStart(7)} visual ${String(step.visual).padStart(7)} following ${step.before.following}->${step.after.following} ${formatWrites(step.writes)}${step.rowChanges.length ? ` rows: ${step.rowChanges.join(" ")}` : ""}`, + ); + } + } + for (const key of [ + "arriveGap120", + "arriveGap400", + "arriveFollowing", + "approveGap120", + "approveGap400", + "approveFollowing", + ]) { + const arrive = results.scenarios[key]; + if (!arrive) continue; + console.log( + `${key}: parked gap=${arrive.positioned.gap} following=${arrive.positioned.following} -> tool_call arrives: scrollTop moved ${arrive.moved}px, VISUAL ${arrive.visual}px (scrollHeight +${arrive.scrollHeightDelta}) final gap=${arrive.settled.gap} following=${arrive.settled.following} writes=${formatWrites(arrive.writes)} rows: ${arrive.rowChanges.join(" ") || "none"}`, + ); + const growth = arrive.composerGrowth; + if (growth) { + console.log( + `${key} + composer grows ${growth.barPx}px: scrollTop moved ${growth.moved}px, VISUAL ${growth.visual}px (scrollHeight +${growth.scrollHeightDelta}) gap ${growth.positioned.gap}->${growth.settled.gap} following ${growth.positioned.following}->${growth.settled.following} writes=${formatWrites(growth.writes)}`, + ); + } + } + const askDown = results.scenarios.askScrollDown; + if (askDown) { + console.log(`askScrollDown: steps=${askDown.steps.length}`); + for (const step of askDown.steps) { + const tag = step.jump ? "JUMP" : " "; + console.log( + `${tag} #${String(step.i).padStart(3)} gap ${String(step.before.gap).padStart(6)} -> ${String(step.after.gap).padStart(6)} delta ${String(step.delta).padStart(7)} visual ${String(step.visual).padStart(7)} following ${step.before.following}->${step.after.following} ${formatWrites(step.writes)}`, + ); + } + } + for (const key of ["askHold120", "askHold400"]) { + const hold = results.scenarios[key]; + if (!hold) continue; + console.log( + `${key}: parked gap=${hold.positioned.gap} following=${hold.positioned.following} rowH=${hold.positioned.lastRowHeight} -> after 4s moved ${hold.moved}px, gaps=[${hold.samples.map((s) => s.gap).join(",")}] rowH=[${hold.samples.map((s) => s.lastRowHeight).join(",")}] writes=${formatWrites(hold.writes)}`, + ); + } + for (const key of ["growthGap100", "growthGap400", "appendGap100", "appendGap400"]) { + const growth = results.scenarios[key]; + if (!growth) continue; + console.log( + `${key}: detached=${!growth.afterDetach.following} parkedGap=${growth.positioned.gap} following=${growth.positioned.following} -> after change scrollTop moved ${growth.moved}px, VISUAL ${growth.visual ?? "n/a"}px (scrollHeight +${growth.scrollHeightDelta}) gap=${growth.grown.gap} following=${growth.grown.following} writes=${formatWrites(growth.writes)}`, + ); + } + if (results.console.length) { + console.log(`console (${results.console.length}):`); + for (const entry of results.console.slice(0, 12)) console.log(` [${entry.type}] ${entry.text}`); + } + console.log(`written: ${outPath}`); + cdp.ws.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/crates/agent-gateway/web/test/browser/scroll-follow-probe.eval.mjs b/crates/agent-gateway/web/test/browser/scroll-follow-probe.eval.mjs new file mode 100644 index 000000000..a1d78711c --- /dev/null +++ b/crates/agent-gateway/web/test/browser/scroll-follow-probe.eval.mjs @@ -0,0 +1,62 @@ +// Ad-hoc CDP evaluator against the probe page that is already open in the +// headless browser on CDP_PORT. Never navigates, so the page state (rows, +// follow state, logs) survives between calls. +// +// node test/browser/scroll-follow-probe.eval.mjs "" +// +// The expression is evaluated with returnByValue + awaitPromise and printed as +// JSON. Wrap DOM-heavy inspection in an IIFE that returns plain data. +const PORT = Number(process.env.CDP_PORT ?? 9333); +const expression = process.argv.slice(2).join(" "); +if (!expression) { + console.error("usage: node scroll-follow-probe.eval.mjs ''"); + process.exit(2); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function getPageTarget() { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${PORT}/json/list`); + const targets = await response.json(); + const page = + targets.find((target) => target.type === "page" && /scroll-follow-probe/.test(target.url)) ?? + targets.find((target) => target.type === "page"); + if (page) return page; + } catch {} + await sleep(250); + } + throw new Error(`no CDP page target on port ${PORT}`); +} + +const target = await getPageTarget(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, { once: true }); + ws.addEventListener("error", reject, { once: true }); +}); +const reply = new Promise((resolve) => { + ws.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (message.id === 1) resolve(message); + }); +}); +ws.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { expression, returnByValue: true, awaitPromise: true }, + }), +); +const message = await reply; +ws.close(); +if (message.error) { + console.error(JSON.stringify(message.error, null, 2)); + process.exit(1); +} +if (message.result.exceptionDetails) { + console.error(JSON.stringify(message.result.exceptionDetails, null, 2).slice(0, 2000)); + process.exit(1); +} +console.log(JSON.stringify(message.result.result.value, null, 2)); diff --git a/crates/agent-gateway/web/test/browser/scroll-follow-probe.html b/crates/agent-gateway/web/test/browser/scroll-follow-probe.html new file mode 100644 index 000000000..e3b581def --- /dev/null +++ b/crates/agent-gateway/web/test/browser/scroll-follow-probe.html @@ -0,0 +1,555 @@ + + + + + + + Scroll follow probe + + + +
+ + + From 268917ae7300e25f4eb4caca8ad62fcfbc00b33a Mon Sep 17 00:00:00 2001 From: AlphaCat Date: Mon, 14 Sep 2026 22:42:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(gui):=20=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E5=8C=BA=E5=90=8C=E6=A0=B7=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20192px=20=E5=9B=9E=E8=B4=B4=E5=8C=BA=E5=90=B8=E9=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面端 ChatTranscript 的 useScrollFollow 显式传 reattachZonePx: BOTTOM_REATTACH_ZONE_PX, 滚轮下行进入距底 192px 的那一 tick 直接 pin 到底,从本分支起的开发版上仍能看到 "快到最新回复结尾时上跳一下"。与 WebUI 同口径改为 0:只在真正到达底部(8px 容差) 时恢复跟随,到达底部后向下滚轮、手势落底、指针在底部释放仍会重新贴底。 BOTTOM_REATTACH_ZONE_PX 保留:它仍是桌面端底部预留带与 WebUI spacer 的最小值, scrollFollowCore 的注释改写为现状说明。chat-transcript-scroller 契约测试新增一例 锁住零回贴区配置与预留带取值。 Co-authored-by: Claude Fable 5.1 --- .../src/pages/chat/transcript/ChatTranscript.tsx | 6 +++++- .../test/chat/chat-transcript-scroller.test.mjs | 10 ++++++++++ .../src/lib/chat-scroll/scrollFollowCore.ts | 16 +++++++++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index da7b6a3b9..c9c87cd8d 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -93,7 +93,11 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript viewport: scrollViewport, listenerRoot: scrollViewport, trackKeys: true, - config: { reattachZonePx: BOTTOM_REATTACH_ZONE_PX }, + // 回贴区为 0:只有真正到达底部(8px 容差内)才恢复跟随。192px 回贴区会在 + // 滚轮下行进入该区间的那一 tick 直接 pin 到底,读者看到的是正文突然上跳; + // 到达底部后向下滚轮、手势落底、指针在底部释放仍会重新贴底。底部预留带 + // 仍以 BOTTOM_REATTACH_ZONE_PX 为最小值(见 transcriptBottomReservePx)。 + config: { reattachZonePx: 0 }, }); // Earlier-history paging lives in TranscriptList next to the virtualizer: 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 d73be7608..93fc91439 100644 --- a/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs +++ b/crates/agent-gui/test/chat/chat-transcript-scroller.test.mjs @@ -25,6 +25,16 @@ test("chat transcript uses one native viewport for scrolling and follow listener assert.match(source, /\[overflow-anchor:none\]/); }); +test("the transcript follow engine runs without a reattach zone", () => { + // The 192px zone pinned the viewport on the first wheel tick that landed + // inside it (a 231px visible jump measured in the WebUI probe page). The + // constant still sizes the bottom reserve band, so the import stays. + const call = source.match(/useScrollFollow\(\{[\s\S]*?\}\);/); + assert.ok(call, "ChatTranscript wires the transcript follow engine"); + assert.match(call[0], /reattachZonePx:\s*0\b/); + assert.match(source, /Math\.max\(BOTTOM_REATTACH_ZONE_PX,/); +}); + test("earlier-history rejection is handled before pagination cleanup", () => { assert.match( transcriptListSource, diff --git a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts index 0422d3198..ac6fa6214 100644 --- a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts +++ b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts @@ -42,13 +42,15 @@ // that boundary and can never re-attach at the physical clamp. export const BOTTOM_ATTACH_THRESHOLD_PX = 8; -// ChatTranscript reserves max(192, composer height + 12)px of blank space -// below the last message so content clears the floating composer. Users -// naturally stop "at the bottom" inside that band, dozens of px short of the -// physical clamp, so a clamp-only check could never re-engage them. Any -// gesture-latched downward arrival inside this zone counts as "scrolled back -// to the bottom". ChatTranscript imports this constant to keep the reserve -// band and the zone equal. +// Reserve-band minimum: ChatTranscript reserves max(192, composer height + +// 12)px of blank space below the last message (the WebUI spacer mirrors it as +// 12rem) so content clears the floating composer. It also sizes the default +// reattach zone, but both transcript hosts now pass `reattachZonePx: 0`: a +// gesture-latched arrival inside the zone pinned the viewport to the clamp in +// one write, which reads as the page snapping up (231px measured in the WebUI +// probe page). Attach happens at the physical clamp only; wheel-down at the +// clamp and a release at the clamp still re-engage. The thinking-block +// scroller passes 0 as well. export const BOTTOM_REATTACH_ZONE_PX = 192; // Gap wiggle inside this slop is layout noise (virtualizer measurement