diff --git a/src/components/ai-elements/message-thread.test.tsx b/src/components/ai-elements/message-thread.test.tsx index 277242aa02..98673888d4 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,286 @@ 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 + + /** 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, deltaX = 0) => + target.dispatchEvent( + new WheelEvent("wheel", { deltaX, deltaY, bubbles: true }) + ) + + const touch = ( + target: Element, + type: string, + clientY: number, + clientX = 100 + ) => { + const event = new Event(type, { bubbles: true }) + Object.defineProperty(event, "touches", { + 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. + 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) + }) + + // 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() + + 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) + }) + + // 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 + 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..379309a422 100644 --- a/src/components/ai-elements/message-thread.tsx +++ b/src/components/ai-elements/message-thread.tsx @@ -84,6 +84,175 @@ 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 + // 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() + } + + // 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) => { + const touch = event.touches.length === 1 ? event.touches[0] : null + touchFrom = touch ? { x: touch.clientX, y: touch.clientY } : null + } + const onTouchMove = (event: TouchEvent) => { + if (touchFrom === null || event.touches.length !== 1) return + // Finger moving down drags the content down: scrolling towards the top. + const { clientX: x, clientY: y } = event.touches[0] + if (y < touchFrom.y) { + touchFrom = { x, y } + return + } + 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 = () => { + touchFrom = 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 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) => { + draggingScrollbar = event.target === viewport && event.button === 0 + 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 +270,7 @@ export const MessageThread = ({ {(context) => ( <> + {typeof children === "function" ? children(context) : children} )}