Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/agent-gui/test/chat/scroll-follow-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
DEFAULT_FOLLOW_CONFIG,
createFollowState,
isDominantVerticalWheel,
isPointInNativeScrollbarGutter,
reduceFollowEvent,
} = createTsModuleLoader().loadModule("@liveagent/ui/lib/chat-scroll/scrollFollowCore");

Expand Down Expand Up @@ -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 }),
Expand Down
192 changes: 192 additions & 0 deletions crates/agent-gui/test/chat/use-scroll-follow-native-scrollbar.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
42 changes: 42 additions & 0 deletions crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 36 additions & 6 deletions crates/agent-ui/src/lib/chat-scroll/useScrollFollow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type FollowEvent,
type FollowState,
isDominantVerticalWheel,
isPointInNativeScrollbarGutter,
POINTER_DRAG_SLOP_PX,
reduceFollowEvent,
SCROLL_FOLLOW_IGNORE_KEYS_ATTRIBUTE,
Expand Down Expand Up @@ -289,19 +290,48 @@ 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;
}
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" });
}
Expand Down
Loading