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
299 changes: 296 additions & 3 deletions src/components/ai-elements/message-thread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
})
})
Loading
Loading