diff --git a/crates/agent-gui/test/chat/scroll-follow-core.test.mjs b/crates/agent-gui/test/chat/scroll-follow-core.test.mjs index 213a24e5d..4afa734e1 100644 --- a/crates/agent-gui/test/chat/scroll-follow-core.test.mjs +++ b/crates/agent-gui/test/chat/scroll-follow-core.test.mjs @@ -8,6 +8,7 @@ const { DEFAULT_FOLLOW_CONFIG, createFollowState, isDominantVerticalWheel, + isPointInNativeScrollbarGutter, reduceFollowEvent, } = createTsModuleLoader().loadModule("@liveagent/ui/lib/chat-scroll/scrollFollowCore"); @@ -188,6 +189,75 @@ test("a scrollbar track click detaches while following", () => { assert.equal(pin, false); }); +test("native scrollbar thumb drag detaches through scroll events alone", () => { + // A native thumb drag delivers no pointermove to the page (Chromium/WebKit + // route it to the scrollbar), only scroll events. Without the pointerdown + // promotion the drag reads as "following + gap opened" and the corrector + // re-pins every frame — the thumb looks glued to the bottom. With the + // promotion the first away-movement detaches and the drag stays free. + const glued = run([growth(0), { type: "pointerDown" }, scroll(120, 10), scroll(260, 20)]); + assert.equal(glued.state.following, true, "precondition: unpromoted press gets corrected"); + assert.equal(glued.pin, true); + + const drag = run([ + growth(0), + { type: "pointerDown" }, + { type: "pointerDragStart" }, // hook: press landed in the native scrollbar gutter + scroll(120, 10), + scroll(260, 20), + scroll(800, 30), + ]); + assert.equal(drag.state.following, false); + assert.equal(drag.pin, false); + // Releasing mid-history stays detached; dragging back down and releasing + // inside the zone re-engages exactly like a custom-scrollbar drag. + const releasedHigh = run([{ type: "pointerRelease", gap: 800 }], { state: drag.state }); + assert.equal(releasedHigh.state.following, false); + const releasedLow = run([scroll(300, 40), scroll(40, 50), { type: "pointerRelease", gap: 40 }], { + state: drag.state, + }); + assert.equal(releasedLow.state.following, true); + assert.equal(releasedLow.pin, true); +}); + +test("native scrollbar gutter hit test: border box minus client box", () => { + // 300x400 border box at (100,50); a 6px vertical scrollbar on the right + // (clientWidth 294) and a 6px horizontal one at the bottom (clientHeight 394). + const box = { + left: 100, + top: 50, + width: 300, + height: 400, + clientLeft: 0, + clientTop: 0, + clientWidth: 294, + clientHeight: 394, + }; + // Content presses. + assert.equal(isPointInNativeScrollbarGutter(100, 50, box), false); + assert.equal(isPointInNativeScrollbarGutter(250, 200, box), false); + assert.equal(isPointInNativeScrollbarGutter(393.9, 443.9, box), false); + // Vertical scrollbar column, including a `scrollbar-gutter: stable` reserve + // (the client box already excludes it). + assert.equal(isPointInNativeScrollbarGutter(394, 200, box), true); + assert.equal(isPointInNativeScrollbarGutter(399, 200, box), true); + // Horizontal scrollbar row. + assert.equal(isPointInNativeScrollbarGutter(200, 444, box), true); + // Outside the element entirely (a press on a sibling that bubbled through + // a listener root) is never a scrollbar press. + assert.equal(isPointInNativeScrollbarGutter(400, 200, box), false); + assert.equal(isPointInNativeScrollbarGutter(99, 200, box), false); + assert.equal(isPointInNativeScrollbarGutter(200, 450, box), false); + // No scrollbar at all (clientWidth == width): nothing qualifies. + const noBar = { ...box, clientWidth: 300, clientHeight: 400 }; + assert.equal(isPointInNativeScrollbarGutter(399, 449, noBar), false); + // RTL / left-side scrollbar: clientLeft carries the bar width. + const rtl = { ...box, clientLeft: 6, clientWidth: 294, clientHeight: 400 }; + assert.equal(isPointInNativeScrollbarGutter(103, 200, rtl), true); + assert.equal(isPointInNativeScrollbarGutter(106, 200, rtl), false); + assert.equal(isPointInNativeScrollbarGutter(399, 200, rtl), false); +}); + test("held pointer suppresses zone attach; release inside the zone re-engages", () => { const midDrag = run([ wheelUp({ now: 0 }), diff --git a/crates/agent-gui/test/chat/use-scroll-follow-native-scrollbar.test.mjs b/crates/agent-gui/test/chat/use-scroll-follow-native-scrollbar.test.mjs new file mode 100644 index 000000000..5d0ee3bc0 --- /dev/null +++ b/crates/agent-gui/test/chat/use-scroll-follow-native-scrollbar.test.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// Regression for the "scrollbar thumb glued to the bottom" bug: the transcript +// (GUI) and the Base UI viewport (WebUI, native bar hidden but still the +// scroller) both run useScrollFollow on a native-scrollbar viewport. A native +// thumb drag emits scroll events only — no pointermove reaches the page — so +// the hook must promote a press inside the scrollbar gutter to a drag on +// pointerdown, otherwise the corrector re-pins on every drag frame. + +const env = await createDomTestEnv(); +const { useScrollFollow } = env.loadModule("@liveagent/ui/lib/chat-scroll/useScrollFollow"); +const React = env.React; + +// jsdom has no layout: fake a 300x400 viewport with a 6px vertical scrollbar +// and 2000px of content, and make scrollTop writable + scroll-event driven. +function installGeometry(el, { width = 300, height = 400, barWidth = 6, scrollHeight = 2000 }) { + let scrollTop = 0; + Object.defineProperties(el, { + clientWidth: { value: width - barWidth, configurable: true }, + clientHeight: { value: height, configurable: true }, + clientLeft: { value: 0, configurable: true }, + clientTop: { value: 0, configurable: true }, + scrollHeight: { value: scrollHeight, configurable: true }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = Math.max(0, Math.min(scrollHeight - height, value)); + }, + }, + }); + el.getBoundingClientRect = () => ({ + left: 100, + top: 50, + width, + height, + right: 100 + width, + bottom: 50 + height, + x: 100, + y: 50, + toJSON() {}, + }); +} + +function pointer(type, target, init) { + const Ctor = env.dom.window.PointerEvent ?? env.dom.window.MouseEvent; + const event = new Ctor(type, { bubbles: true, cancelable: true, ...init }); + if (!("pointerType" in event) || event.pointerType === undefined) { + Object.defineProperty(event, "pointerType", { value: init.pointerType ?? "mouse" }); + } + if (event.button === undefined || event.button !== (init.button ?? 0)) { + Object.defineProperty(event, "button", { value: init.button ?? 0 }); + } + if (init.buttons !== undefined && event.buttons !== init.buttons) { + Object.defineProperty(event, "buttons", { value: init.buttons }); + } + target.dispatchEvent(event); + return event; +} + +function mount() { + const container = env.dom.window.document.createElement("div"); + env.dom.window.document.body.appendChild(container); + const root = env.createRoot(container); + const captured = { handle: null, following: null, viewport: null }; + + function Harness() { + const [viewport, setViewport] = React.useState(null); + const setRef = React.useCallback((el) => { + if (el) installGeometry(el, {}); + setViewport(el); + }, []); + const { handle, following } = useScrollFollow({ + viewport, + listenerRoot: viewport, + }); + captured.handle = handle; + captured.following = following; + captured.viewport = viewport; + return React.createElement( + "div", + { ref: setRef, "data-scroll-viewport": "" }, + React.createElement("div", null, "content"), + ); + } + + env.act(() => { + root.render(React.createElement(Harness)); + }); + return { root, container, captured }; +} + +// Simulate a native thumb drag: the browser moves scrollTop and emits scroll +// events; no pointermove reaches the page. +function nativeDragTo(viewport, ...tops) { + for (const top of tops) { + env.act(() => { + viewport.scrollTop = top; + viewport.dispatchEvent(new env.dom.window.Event("scroll")); + }); + } +} + +test("a mouse press inside the native scrollbar gutter lets a thumb drag detach follow", () => { + const { root, captured } = mount(); + const viewport = captured.viewport; + assert.ok(viewport); + assert.equal(captured.following, true); + // Mount pin. + assert.equal(viewport.scrollTop, 1600); + + // Press at x=397: inside the 300px border box (100..400) but past the + // 294px client box — the vertical scrollbar column. + env.act(() => { + pointer("pointerdown", viewport, { clientX: 397, clientY: 200, pointerType: "mouse" }); + }); + nativeDragTo(viewport, 1400, 1100, 600); + + assert.equal(captured.following, false, "thumb drag must detach follow"); + assert.equal(viewport.scrollTop, 600, "drag position must not be corrected back to the bottom"); + + // Release far from the bottom stays detached. + env.act(() => { + pointer("pointerup", env.dom.window, { clientX: 397, clientY: 120, pointerType: "mouse" }); + }); + assert.equal(captured.following, false); + assert.equal(viewport.scrollTop, 600); + env.act(() => root.unmount()); +}); + +test("dragging the native thumb back to the bottom and releasing re-engages follow", () => { + const { root, captured } = mount(); + const viewport = captured.viewport; + + env.act(() => { + pointer("pointerdown", viewport, { clientX: 397, clientY: 200, pointerType: "mouse" }); + }); + nativeDragTo(viewport, 900, 300); + assert.equal(captured.following, false); + nativeDragTo(viewport, 1200, 1550); + // Still held: no attach mid-drag. + assert.equal(captured.following, false); + env.act(() => { + pointer("pointerup", env.dom.window, { clientX: 397, clientY: 380, pointerType: "mouse" }); + }); + assert.equal(captured.following, true); + assert.equal(viewport.scrollTop, 1600, "release inside the zone pins to the bottom"); + env.act(() => root.unmount()); +}); + +test("a static content press followed by a layout echo is still corrected", () => { + // The slop gate for content clicks is unchanged: a press inside the client + // box never counts as a scrollbar press, so a scroll event that opens a gap + // with no drag is treated as noise and re-pinned. + const { root, captured } = mount(); + const viewport = captured.viewport; + + env.act(() => { + pointer("pointerdown", viewport, { clientX: 200, clientY: 200, pointerType: "mouse" }); + }); + nativeDragTo(viewport, 1500); + assert.equal(captured.following, true); + assert.equal(viewport.scrollTop, 1600, "content press + gap is corrected back to the bottom"); + env.act(() => { + pointer("pointerup", env.dom.window, { clientX: 200, clientY: 200, pointerType: "mouse" }); + }); + env.act(() => root.unmount()); +}); + +test("a touch press in the gutter column is not a scrollbar press", () => { + // Touch never grabs a native thumb; a finger landing over the gutter is a + // content touch and goes through the touchmove path instead. + const { root, captured } = mount(); + const viewport = captured.viewport; + + env.act(() => { + pointer("pointerdown", viewport, { clientX: 397, clientY: 200, pointerType: "touch" }); + }); + nativeDragTo(viewport, 1500); + assert.equal(captured.following, true); + assert.equal(viewport.scrollTop, 1600); + env.act(() => { + pointer("pointerup", env.dom.window, { clientX: 397, clientY: 200, pointerType: "touch" }); + }); + env.act(() => root.unmount()); +}); + +test.after(() => { + env.cleanup(); +}); diff --git a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts index 9f42d9250..0422d3198 100644 --- a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts +++ b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts @@ -60,6 +60,48 @@ export const DIRECTION_SLOP_PX = 1; // can never read as "dragged away from the bottom". export const POINTER_DRAG_SLOP_PX = 4; +// Geometry of a scroll container for native-scrollbar hit testing, all in the +// same CSS-px coordinate space (getBoundingClientRect + client* metrics). +export type ScrollerBox = { + // Border box (getBoundingClientRect). + left: number; + top: number; + width: number; + height: number; + // Client box offset from the border box origin (clientLeft/clientTop). In + // RTL clientLeft already includes a left-side vertical scrollbar. + clientLeft: number; + clientTop: number; + // Client box size (clientWidth/clientHeight) — excludes scrollbars and any + // `scrollbar-gutter: stable` reserve. + clientWidth: number; + clientHeight: number; +}; + +// A press inside the border box but outside the client box landed on the +// element's own native scrollbar (thumb, track or reserved gutter). Native +// thumb drags deliver no pointermove to the page — Chromium and WebKit route +// them to the scrollbar — only scroll events, so the movement-slop promotion +// in the hook can never fire; the hook must promote such a press to a drag +// on pointerdown, exactly like a press on a custom scrollbar element. +// Border pixels also read as "scrollbar": the scrollers using this engine +// have no borders, and a press on a border is never content interaction. +export function isPointInNativeScrollbarGutter(x: number, y: number, box: ScrollerBox) { + const insideBorderBox = + x >= box.left && x < box.left + box.width && y >= box.top && y < box.top + box.height; + if (!insideBorderBox) { + return false; + } + const clientX0 = box.left + box.clientLeft; + const clientY0 = box.top + box.clientTop; + const insideClientBox = + x >= clientX0 && + x < clientX0 + box.clientWidth && + y >= clientY0 && + y < clientY0 + box.clientHeight; + return !insideClientBox; +} + // Elements carrying this attribute keep their arrow/Home/End keys to // themselves: the transcript width handles resize on those keys, which must // not also read as a scroll intent and detach bottom-follow. useScrollFollow diff --git a/crates/agent-ui/src/lib/chat-scroll/useScrollFollow.ts b/crates/agent-ui/src/lib/chat-scroll/useScrollFollow.ts index baa7bfe4e..145e69ba2 100644 --- a/crates/agent-ui/src/lib/chat-scroll/useScrollFollow.ts +++ b/crates/agent-ui/src/lib/chat-scroll/useScrollFollow.ts @@ -7,6 +7,7 @@ import { type FollowEvent, type FollowState, isDominantVerticalWheel, + isPointInNativeScrollbarGutter, POINTER_DRAG_SLOP_PX, reduceFollowEvent, SCROLL_FOLLOW_IGNORE_KEYS_ATTRIBUTE, @@ -289,6 +290,26 @@ export function useScrollFollow(args: UseScrollFollowArgs): { // can swallow the matching pointerup. let pointerDownX = 0; let pointerDownY = 0; + // The viewport's own native scrollbar has no DOM node to hit-test, so a + // press is classified geometrically: inside the border box but outside + // the client box. Only mouse presses qualify — touch scrolls the content + // and never grabs a native thumb, and a stylus press is not a scroll intent. + const isPressOnNativeScrollbar = (event: PointerEvent) => { + if (event.pointerType !== "mouse" || event.target !== viewport) { + return false; + } + const rect = viewport.getBoundingClientRect(); + return isPointInNativeScrollbarGutter(event.clientX, event.clientY, { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + clientLeft: viewport.clientLeft, + clientTop: viewport.clientTop, + clientWidth: viewport.clientWidth, + clientHeight: viewport.clientHeight, + }); + }; const handlePointerDown = (event: PointerEvent) => { if (event.pointerType === "mouse" && event.button === 2) { return; @@ -296,12 +317,21 @@ export function useScrollFollow(args: UseScrollFollowArgs): { pointerDownX = event.clientX; pointerDownY = event.clientY; dispatch({ type: "pointerDown" }); - // A press on the custom scrollbar is unambiguous scroll intent, and a - // track click jumps scrollTop synchronously on pointerdown with zero - // pointer movement — the movement-slop promotion below would never fire - // and the corrector would undo the jump. Content clicks keep the slop - // gate (static click + layout echo must not read as a drag). - if (event.target instanceof Element && event.target.closest("[data-scroll-area-scrollbar]")) { + // A press on a scrollbar is unambiguous scroll intent, and neither kind + // can rely on the movement-slop promotion below: + // - a custom-scrollbar track click jumps scrollTop synchronously on + // pointerdown with zero pointer movement, so the corrector would undo + // the jump; + // - a native thumb drag delivers no pointermove to the page at all + // (Chromium/WebKit route it to the scrollbar), only scroll events, so + // the drag would read as "following + gap opened" and get re-pinned to + // the bottom on every frame — the thumb looks glued in place. + // Content clicks keep the slop gate (static click + layout echo must not + // read as a drag). + if ( + (event.target instanceof Element && event.target.closest("[data-scroll-area-scrollbar]")) || + isPressOnNativeScrollbar(event) + ) { cancelJumpAnimation(); dispatch({ type: "pointerDragStart" }); }