From 48a22ef2b1b4b873146fd4404fca1db93b86e7b9 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:29:33 -0700 Subject: [PATCH 1/2] fix(chat): release the bottom lock as soon as the user scrolls up Scrolling a transcript while a reply streams (or while virtua is still measuring rows) could pull the view back to the bottom, on desktop and on mobile. use-stick-to-bottom only learns about an escape two ways. Its wheel listener escapes only when the nearest overflow:auto ancestor of the event target is the viewport, so a wheel over a code block, table or tool output never escapes. Its scroll listener drops every scroll event that lands while a content resize is in flight, and streaming plus virtua's row measurement keep one in flight almost constantly, so touch, scrollbar and keyboard scrolls are dropped too. The lock stays engaged while the user reads history, and the next content growth or viewport resize scrolls them back down. Read the intent from the input instead: an upward wheel not taken by a nested scroller, a finger dragging the transcript down, an upward scroll key on the focused viewport, or an upward scrollbar drag now release the lock through the library's stopScroll. Following the stream while pinned, the scroll-to-bottom button and jump actions are unchanged. --- .../ai-elements/message-thread.test.tsx | 176 +++++++++++++++++- src/components/ai-elements/message-thread.tsx | 158 ++++++++++++++++ 2 files changed, 331 insertions(+), 3 deletions(-) diff --git a/src/components/ai-elements/message-thread.test.tsx b/src/components/ai-elements/message-thread.test.tsx index 277242aa02..e863904727 100644 --- a/src/components/ai-elements/message-thread.test.tsx +++ b/src/components/ai-elements/message-thread.test.tsx @@ -2,12 +2,19 @@ import type { ReactNode } from "react" import { act, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -// A stand-in for the slice of `StickToBottomContext` the viewport sticker uses, -// so a test can drive `isAtBottom` / inspect `resizeDifference` directly. +// A stand-in for the slice of `StickToBottomContext` the viewport sticker and +// the escape listener use, so a test can drive `isAtBottom` / inspect +// `resizeDifference` directly. const testState = vi.hoisted(() => ({ scrollRef: { current: null as HTMLDivElement | null }, scrollToBottom: vi.fn(), - state: { isAtBottom: true, resizeDifference: 0 }, + stopScroll: vi.fn(), + state: { + isAtBottom: true, + escapedFromLock: false, + resizeDifference: 0, + animation: undefined as { ignoreEscapes: boolean } | undefined, + }, })) vi.mock("use-stick-to-bottom", () => ({ @@ -61,8 +68,11 @@ beforeEach(() => { disconnects = 0 testState.scrollRef.current = document.createElement("div") testState.scrollToBottom.mockReset() + testState.stopScroll.mockReset() testState.state.isAtBottom = true + testState.state.escapedFromLock = false testState.state.resizeDifference = 0 + testState.state.animation = undefined vi.stubGlobal( "ResizeObserver", class { @@ -162,3 +172,163 @@ describe("MessageThread viewport resize", () => { expect(disconnects).toBe(1) }) }) + +describe("MessageThread escape on user scroll", () => { + /** Give an element a scrollable box, which jsdom never lays out. */ + const makeScrollable = ( + el: HTMLElement, + { scrollHeight = 2000, clientHeight = 400, scrollTop = 1600 } = {} + ) => { + Object.defineProperty(el, "scrollHeight", { + configurable: true, + value: scrollHeight, + }) + Object.defineProperty(el, "clientHeight", { + configurable: true, + value: clientHeight, + }) + el.scrollTop = scrollTop + } + + const viewport = () => testState.scrollRef.current as HTMLDivElement + + /** Mount with a scrollable viewport and a plain row inside it. */ + const mountScrollable = () => { + makeScrollable(viewport()) + const row = document.createElement("div") + viewport().appendChild(row) + mountThreadAt(400) + return row + } + + const wheel = (target: Element, deltaY: number) => + target.dispatchEvent(new WheelEvent("wheel", { deltaY, bubbles: true })) + + const touch = (target: Element, type: string, clientY: number) => { + const event = new Event(type, { bubbles: true }) + Object.defineProperty(event, "touches", { + value: type === "touchend" ? [] : [{ clientY }], + }) + target.dispatchEvent(event) + } + + // The reported bug, desktop: the library only escapes a wheel whose nearest + // `overflow: auto` ancestor is the viewport, so wheeling up over a code + // block (overflow-auto, nothing to scroll vertically) kept the lock. + it("escapes on an upward wheel over a nested horizontal scroller", () => { + mountScrollable() + const code = document.createElement("pre") + code.style.overflow = "auto" + viewport().appendChild(code) + + wheel(code, -40) + + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + + it("leaves the lock alone on a downward wheel", () => { + const row = mountScrollable() + + wheel(row, 40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("lets a nested vertical scroller that can still scroll up take the wheel", () => { + mountScrollable() + const output = document.createElement("div") + output.style.overflowY = "auto" + makeScrollable(output, { + scrollHeight: 600, + clientHeight: 200, + scrollTop: 50, + }) + viewport().appendChild(output) + + wheel(output, -40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + // The reported bug, mobile: touch scrolls only reach the library as scroll + // events, which it discards while any content resize is in flight. + it("escapes when a finger drags the transcript down, even mid-resize", () => { + const row = mountScrollable() + testState.state.resizeDifference = 24 + + touch(row, "touchstart", 300) + touch(row, "touchmove", 302) + expect(testState.stopScroll).not.toHaveBeenCalled() + + touch(row, "touchmove", 320) + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + + it("does not escape when a finger drags the transcript up", () => { + const row = mountScrollable() + + touch(row, "touchstart", 300) + touch(row, "touchmove", 200) + touch(row, "touchend", 200) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("escapes on keyboard scrolling up but not from an editable field", () => { + const row = mountScrollable() + const input = document.createElement("textarea") + viewport().appendChild(input) + + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }) + ) + expect(testState.stopScroll).not.toHaveBeenCalled() + + row.dispatchEvent( + new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }) + ) + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + + it("does nothing once the lock is already released", () => { + const row = mountScrollable() + testState.state.isAtBottom = false + testState.state.escapedFromLock = true + + wheel(row, -40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("keeps the lock during an ignoreEscapes scroll", () => { + const row = mountScrollable() + testState.state.animation = { ignoreEscapes: true } + + wheel(row, -40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("does nothing when the transcript fits without scrolling", () => { + makeScrollable(viewport(), { scrollHeight: 400, clientHeight: 400 }) + const row = document.createElement("div") + viewport().appendChild(row) + mountThreadAt(400) + + wheel(row, -40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("stops listening when the thread unmounts", () => { + makeScrollable(viewport()) + const row = document.createElement("div") + viewport().appendChild(row) + const { unmount } = mountThreadAt(400) + + unmount() + wheel(row, -40) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ai-elements/message-thread.tsx b/src/components/ai-elements/message-thread.tsx index 4a60125d8a..f082f4c890 100644 --- a/src/components/ai-elements/message-thread.tsx +++ b/src/components/ai-elements/message-thread.tsx @@ -84,6 +84,163 @@ const StickThroughViewportResize = () => { return null } +/** Keys that scroll a focused viewport towards the top of the transcript. */ +const SCROLL_UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]) + +/** A finger has to travel this far down before the drag counts as intent. */ +const TOUCH_ESCAPE_SLOP_PX = 6 + +/** + * Whether something between `target` and the viewport scrolls vertically and + * can still move up, so it — not the transcript — takes the upward gesture. + */ +const nestedScrollerTakesUpward = ( + target: EventTarget | null, + viewport: HTMLElement +): boolean => { + let element = target instanceof Element ? target : null + while (element && element !== viewport) { + if ( + element instanceof HTMLElement && + element.scrollTop > 0 && + element.scrollHeight > element.clientHeight + ) { + const { overflowY } = getComputedStyle(element) + if (overflowY === "auto" || overflowY === "scroll") return true + } + element = element.parentElement + } + return false +} + +const isEditableTarget = (target: EventTarget | null): boolean => + target instanceof HTMLElement && + (target.isContentEditable || target.closest("input,textarea,select") !== null) + +/** + * Releases the bottom lock the moment the user starts scrolling towards older + * messages. Renders nothing; mounted by `MessageThread` beside the viewport + * sticker. + * + * `use-stick-to-bottom` only learns about an escape in two ways, and neither + * holds up under a virtualized, streaming transcript: + * + * - Its wheel listener walks up from the event target to the first element + * whose computed `overflow` is `auto`/`scroll` and escapes only when that is + * the viewport. Code blocks, tables and tool output are `overflow-auto`, so a + * wheel over any of them never escapes. + * - Its scroll listener discards every scroll event that lands while a content + * resize is in flight (`resizeDifference`). Streaming grows the content every + * flush, and virtua re-measures rows as they mount, so touch, scrollbar and + * keyboard scrolls are discarded nearly every time. + * + * The lock then stays engaged while the user reads history, and the next + * content growth (a streamed token, a freshly measured row) or viewport resize + * scrolls them back to the bottom. This reads the user's intent from the input + * itself, which no resize can disguise, and escapes on it. + */ +const EscapeLockOnUserScroll = () => { + const { scrollRef, stopScroll, state } = useStickToBottomContext() + + useEffect(() => { + const viewport = scrollRef.current + if (!viewport) return + + const escape = () => { + if (state.escapedFromLock && !state.isAtBottom) return + // A deliberate `ignoreEscapes` scroll keeps its lock, as in the library. + if (state.animation?.ignoreEscapes) return + if (viewport.scrollHeight <= viewport.clientHeight) return + stopScroll() + } + + const onWheel = (event: WheelEvent) => { + if (event.deltaY >= 0 || event.ctrlKey) return + if (nestedScrollerTakesUpward(event.target, viewport)) return + escape() + } + + let touchY: number | null = null + const onTouchStart = (event: TouchEvent) => { + touchY = event.touches.length === 1 ? event.touches[0].clientY : null + } + const onTouchMove = (event: TouchEvent) => { + if (touchY === null || event.touches.length !== 1) return + // Finger moving down drags the content down: scrolling towards the top. + // Measured from the lowest point so far, so an up-then-down drag counts. + const y = event.touches[0].clientY + if (y < touchY) { + touchY = y + return + } + if (y - touchY < TOUCH_ESCAPE_SLOP_PX) return + touchY = null + if (nestedScrollerTakesUpward(event.target, viewport)) return + escape() + } + const onTouchEnd = () => { + touchY = null + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey) return + if (event.metaKey && event.key !== "ArrowUp") return + const upward = + SCROLL_UP_KEYS.has(event.key) || (event.key === " " && event.shiftKey) + if (!upward || isEditableTarget(event.target)) return + if (nestedScrollerTakesUpward(event.target, viewport)) return + escape() + } + + // Dragging the native scrollbar: a press on the viewport outside its + // client box, then any upward scroll until the press ends. + let draggingScrollbar = false + let lastScrollTop = viewport.scrollTop + const onPointerDown = (event: PointerEvent) => { + if (event.target !== viewport || event.button !== 0) return + const rect = viewport.getBoundingClientRect() + const clientStart = rect.left + viewport.clientLeft + const x = event.clientX + draggingScrollbar = + x < clientStart || x > clientStart + viewport.clientWidth + lastScrollTop = viewport.scrollTop + } + const onPointerEnd = () => { + draggingScrollbar = false + } + const onScroll = () => { + const { scrollTop } = viewport + if (draggingScrollbar && scrollTop < lastScrollTop) escape() + lastScrollTop = scrollTop + } + + viewport.addEventListener("wheel", onWheel, { passive: true }) + viewport.addEventListener("touchstart", onTouchStart, { passive: true }) + viewport.addEventListener("touchmove", onTouchMove, { passive: true }) + viewport.addEventListener("touchend", onTouchEnd, { passive: true }) + viewport.addEventListener("touchcancel", onTouchEnd, { passive: true }) + viewport.addEventListener("keydown", onKeyDown) + viewport.addEventListener("pointerdown", onPointerDown) + viewport.addEventListener("scroll", onScroll, { passive: true }) + window.addEventListener("pointerup", onPointerEnd) + window.addEventListener("pointercancel", onPointerEnd) + return () => { + viewport.removeEventListener("wheel", onWheel) + viewport.removeEventListener("touchstart", onTouchStart) + viewport.removeEventListener("touchmove", onTouchMove) + viewport.removeEventListener("touchend", onTouchEnd) + viewport.removeEventListener("touchcancel", onTouchEnd) + viewport.removeEventListener("keydown", onKeyDown) + viewport.removeEventListener("pointerdown", onPointerDown) + viewport.removeEventListener("scroll", onScroll) + window.removeEventListener("pointerup", onPointerEnd) + window.removeEventListener("pointercancel", onPointerEnd) + } + }, [scrollRef, stopScroll, state]) + + return null +} + export type MessageThreadProps = ComponentProps export const MessageThread = ({ @@ -101,6 +258,7 @@ export const MessageThread = ({ {(context) => ( <> + {typeof children === "function" ? children(context) : children} )} From 0010c587e237d41a70ac88f056d1175087e18269 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 14:22:55 +0800 Subject: [PATCH 2/2] fix(chat): keep the bottom lock through sideways gestures and overlay scrollbars A mostly sideways wheel or touch pan across a code block or table drifts a few pixels vertically. The escape listener released the lock on that drift even though the horizontal scroller took the gesture and the transcript never moved, so the streaming reply stopped being followed. A wheel whose horizontal delta dominates is now ignored, and a touch settles its axis once, when it first travels past the slop. The scrollbar-drag detector looked for a press outside the viewport's client box. An overlay scrollbar (the macOS default) takes no layout space, so a press on it never matched. A primary press whose target is the viewport element itself now counts as a scrollbar press, and a press on the transcript ends a drag whose release never reached the page. --- .../ai-elements/message-thread.test.tsx | 131 +++++++++++++++++- src/components/ai-elements/message-thread.tsx | 48 ++++--- 2 files changed, 157 insertions(+), 22 deletions(-) diff --git a/src/components/ai-elements/message-thread.test.tsx b/src/components/ai-elements/message-thread.test.tsx index e863904727..98673888d4 100644 --- a/src/components/ai-elements/message-thread.test.tsx +++ b/src/components/ai-elements/message-thread.test.tsx @@ -192,26 +192,51 @@ describe("MessageThread escape on user scroll", () => { const viewport = () => testState.scrollRef.current as HTMLDivElement + /** Where a classic scrollbar sits: just right of the 600px client box. */ + const SCROLLBAR_X = 605 + /** Mount with a scrollable viewport and a plain row inside it. */ const mountScrollable = () => { makeScrollable(viewport()) + Object.defineProperty(viewport(), "clientWidth", { + configurable: true, + value: 600, + }) const row = document.createElement("div") viewport().appendChild(row) mountThreadAt(400) return row } - const wheel = (target: Element, deltaY: number) => - target.dispatchEvent(new WheelEvent("wheel", { deltaY, bubbles: true })) + const wheel = (target: Element, deltaY: number, deltaX = 0) => + target.dispatchEvent( + new WheelEvent("wheel", { deltaX, deltaY, bubbles: true }) + ) - const touch = (target: Element, type: string, clientY: number) => { + const touch = ( + target: Element, + type: string, + clientY: number, + clientX = 100 + ) => { const event = new Event(type, { bubbles: true }) Object.defineProperty(event, "touches", { - value: type === "touchend" ? [] : [{ clientY }], + value: type === "touchend" ? [] : [{ clientX, clientY }], }) target.dispatchEvent(event) } + // jsdom has no PointerEvent; the listener only reads `button`. + const press = (target: Element, clientX = 0) => + target.dispatchEvent( + new MouseEvent("pointerdown", { button: 0, clientX, bubbles: true }) + ) + const release = () => window.dispatchEvent(new MouseEvent("pointerup")) + const scrollViewportTo = (scrollTop: number) => { + viewport().scrollTop = scrollTop + viewport().dispatchEvent(new Event("scroll")) + } + // The reported bug, desktop: the library only escapes a wheel whose nearest // `overflow: auto` ancestor is the viewport, so wheeling up over a code // block (overflow-auto, nothing to scroll vertically) kept the lock. @@ -264,6 +289,42 @@ describe("MessageThread escape on user scroll", () => { expect(testState.stopScroll).toHaveBeenCalledTimes(1) }) + // A trackpad swipe along a wide code block drifts a little up, but the code + // block takes it sideways and the transcript never moves. + it("leaves the lock alone on a sideways wheel that drifts up", () => { + mountScrollable() + const code = document.createElement("pre") + code.style.overflow = "auto" + viewport().appendChild(code) + + wheel(code, -4, 120) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + // Same on touch: panning a code block sideways drifts down a few pixels. + // The axis is settled once per touch, so a later downward drag in the same + // touch does not reopen the question. + it("leaves the lock alone on a sideways pan that drifts down", () => { + const row = mountScrollable() + + touch(row, "touchstart", 300, 200) + touch(row, "touchmove", 304, 150) + touch(row, "touchmove", 310, 60) + touch(row, "touchmove", 400, 60) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("escapes on a diagonal drag that is mostly downward", () => { + const row = mountScrollable() + + touch(row, "touchstart", 300, 200) + touch(row, "touchmove", 320, 210) + + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + it("does not escape when a finger drags the transcript up", () => { const row = mountScrollable() @@ -290,6 +351,68 @@ describe("MessageThread escape on user scroll", () => { expect(testState.stopScroll).toHaveBeenCalledTimes(1) }) + // The reported bug, scrollbar: a drag reaches the library only as scroll + // events, which it discards while a content resize is in flight. + it("escapes when the scrollbar is dragged up, even mid-resize", () => { + mountScrollable() + testState.state.resizeDifference = 24 + + press(viewport(), SCROLLBAR_X) + // While the thumb is held, the stream grows the content and the library + // follows it down to the new bottom: a downward scroll, not an escape. + Object.defineProperty(viewport(), "scrollHeight", { + configurable: true, + value: 2100, + }) + scrollViewportTo(1700) + expect(testState.stopScroll).not.toHaveBeenCalled() + + scrollViewportTo(1500) + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + + // An overlay scrollbar (the macOS default) takes no layout space, so the + // press lands inside the client box. It still targets the viewport itself. + it("recognises a press on an overlay scrollbar drawn inside the client box", () => { + mountScrollable() + + press(viewport(), 590) + scrollViewportTo(1500) + + expect(testState.stopScroll).toHaveBeenCalledTimes(1) + }) + + it("leaves the lock alone when a press on the transcript precedes an upward scroll", () => { + const row = mountScrollable() + + press(row) + scrollViewportTo(1500) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + it("stops reading upward scrolls as a scrollbar drag once the press ends", () => { + mountScrollable() + + press(viewport(), SCROLLBAR_X) + release() + scrollViewportTo(1500) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + + // If the release never reaches the page, the next press on the transcript + // still ends the drag rather than leaving it armed. + it("ends a scrollbar drag on the next press on the transcript", () => { + const row = mountScrollable() + + press(viewport(), SCROLLBAR_X) + press(row) + scrollViewportTo(1500) + + expect(testState.stopScroll).not.toHaveBeenCalled() + }) + it("does nothing once the lock is already released", () => { const row = mountScrollable() testState.state.isAtBottom = false diff --git a/src/components/ai-elements/message-thread.tsx b/src/components/ai-elements/message-thread.tsx index f082f4c890..379309a422 100644 --- a/src/components/ai-elements/message-thread.tsx +++ b/src/components/ai-elements/message-thread.tsx @@ -156,30 +156,43 @@ const EscapeLockOnUserScroll = () => { const onWheel = (event: WheelEvent) => { if (event.deltaY >= 0 || event.ctrlKey) return + // A mostly sideways wheel (a trackpad swipe along a wide code block or + // table) drifts a little up or down, but it is not a scroll towards older + // messages: when a horizontal scroller takes it the transcript never + // moves, so it must not release the lock either. + if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return if (nestedScrollerTakesUpward(event.target, viewport)) return escape() } - let touchY: number | null = null + // Where the finger was at the top of its travel so far, so an up-then-down + // drag is measured from where it turned. + let touchFrom: { x: number; y: number } | null = null const onTouchStart = (event: TouchEvent) => { - touchY = event.touches.length === 1 ? event.touches[0].clientY : null + const touch = event.touches.length === 1 ? event.touches[0] : null + touchFrom = touch ? { x: touch.clientX, y: touch.clientY } : null } const onTouchMove = (event: TouchEvent) => { - if (touchY === null || event.touches.length !== 1) return + if (touchFrom === null || event.touches.length !== 1) return // Finger moving down drags the content down: scrolling towards the top. - // Measured from the lowest point so far, so an up-then-down drag counts. - const y = event.touches[0].clientY - if (y < touchY) { - touchY = y + const { clientX: x, clientY: y } = event.touches[0] + if (y < touchFrom.y) { + touchFrom = { x, y } return } - if (y - touchY < TOUCH_ESCAPE_SLOP_PX) return - touchY = null + const travel = y - touchFrom.y + if (travel < TOUCH_ESCAPE_SLOP_PX) return + // Decided once per touch, the way the browser settles a pan's axis. A + // sideways pan (across a code block or table) that drifts down is not a + // drag of the transcript, so it leaves the lock alone. + const sideways = Math.abs(x - touchFrom.x) > travel + touchFrom = null + if (sideways) return if (nestedScrollerTakesUpward(event.target, viewport)) return escape() } const onTouchEnd = () => { - touchY = null + touchFrom = null } const onKeyDown = (event: KeyboardEvent) => { @@ -192,17 +205,16 @@ const EscapeLockOnUserScroll = () => { escape() } - // Dragging the native scrollbar: a press on the viewport outside its - // client box, then any upward scroll until the press ends. + // Dragging the native scrollbar: a primary press on the viewport element + // itself, then any upward scroll until the press ends. A press on the + // transcript targets the content inside the viewport, so a press whose + // target is the viewport landed on its scrollbar or gutter. That is checked + // by target rather than position because an overlay scrollbar (the macOS + // default) takes no layout space: it is drawn inside the client box. let draggingScrollbar = false let lastScrollTop = viewport.scrollTop const onPointerDown = (event: PointerEvent) => { - if (event.target !== viewport || event.button !== 0) return - const rect = viewport.getBoundingClientRect() - const clientStart = rect.left + viewport.clientLeft - const x = event.clientX - draggingScrollbar = - x < clientStart || x > clientStart + viewport.clientWidth + draggingScrollbar = event.target === viewport && event.button === 0 lastScrollTop = viewport.scrollTop } const onPointerEnd = () => {