fix(web): narrow chat scroll provenance
This commit is contained in:
@@ -157,6 +157,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
/** Simulate reader input before the browser delivers the host scroll event. */
|
||||
function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
|
||||
element.scrollTop = top
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
|
||||
let scrollHeight = initialHeight
|
||||
let scrollTop = 0
|
||||
@@ -168,10 +175,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
|
||||
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
|
||||
})
|
||||
return {
|
||||
setHeight: (value: number) => {
|
||||
scrollHeight = value
|
||||
scrollTop = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight))
|
||||
},
|
||||
setHeight: (value: number) => { scrollHeight = value },
|
||||
setLayout: (height: number, top: number) => {
|
||||
scrollHeight = height
|
||||
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
|
||||
@@ -179,34 +183,6 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulate the browser order: reader input precedes the host scroll event. */
|
||||
function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
|
||||
element.scrollTop = top
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
/** Settle test-installed metrics at the owned floor before reader input. */
|
||||
function settleAtBottom(element: HTMLElement): void {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
function installAnimationFrameQueue() {
|
||||
let callbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback)
|
||||
return callbacks.length
|
||||
})
|
||||
return {
|
||||
flush: () => {
|
||||
const frame = callbacks
|
||||
callbacks = []
|
||||
act(() => { frame.forEach((callback) => { callback(performance.now()) }) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
it('groups consecutive tool results and keeps stable keys', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
@@ -319,7 +295,6 @@ describe('ChatView', () => {
|
||||
)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// The reader moves after the request starts; this, not the click-time
|
||||
@@ -711,7 +686,6 @@ describe('ChatView', () => {
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
|
||||
let anchoredTop = 100
|
||||
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
|
||||
@@ -724,8 +698,6 @@ describe('ChatView', () => {
|
||||
anchoredTop = 700
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
|
||||
fireEvent.scroll(scroller) // delayed event from the semantic correction stays reader-owned
|
||||
expect(scroller.scrollTop).toBe(680)
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
@@ -746,7 +718,6 @@ describe('ChatView', () => {
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
@@ -774,7 +745,6 @@ describe('ChatView', () => {
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
@@ -793,7 +763,6 @@ describe('ChatView', () => {
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
@@ -809,7 +778,6 @@ describe('ChatView', () => {
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 100) // far from bottom
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
@@ -822,31 +790,17 @@ describe('ChatView', () => {
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-pins a UA layout scroll after the bottom write has settled', () => {
|
||||
let notify: (() => void) | undefined
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
notify = () => { callback([], this as unknown as ResizeObserver) }
|
||||
}
|
||||
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 100)
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(scroller.scrollTop).toBe(700)
|
||||
fireEvent.scroll(scroller) // delivery from the completed bottom write
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// A stream-finalization shrink can clamp scrollTop before Markdown reflow
|
||||
// restores the taller layout. The eventual UA scroll has no reader input
|
||||
// and must restore ownership even though it differs from the old target.
|
||||
fireEvent.wheel(scroller, { deltaY: 120 }) // no-op wheel at the physical bottom
|
||||
// The wheel cannot move farther down. A stream-finalization shrink clamps
|
||||
// the old position, then reflow grows the layout before scroll delivery.
|
||||
fireEvent.wheel(scroller, { deltaY: 120 })
|
||||
metrics.setLayout(1_040, 500)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(740)
|
||||
@@ -854,221 +808,24 @@ describe('ChatView', () => {
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { notify?.() })
|
||||
act(() => { h.set({ running: true }) })
|
||||
expect(scroller.scrollTop).toBe(900)
|
||||
})
|
||||
|
||||
it('keeps a reader gesture active until its small scrolls cross the follow threshold', () => {
|
||||
let notify: (() => void) | undefined
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
notify = () => { callback([], this as unknown as ResizeObserver) }
|
||||
}
|
||||
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 100)
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
|
||||
fireEvent.wheel(scroller, { deltaY: -120 })
|
||||
scroller.scrollTop = 695
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
scroller.scrollTop = 680
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
scroller.scrollTop = 650
|
||||
fireEvent.scroll(scroller)
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(650)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
expect(h.chatScroll.read()?.scrollTop).toBe(650)
|
||||
})
|
||||
|
||||
it('keeps the first wheel baseline when browser scroll events coalesce', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
fireEvent.wheel(scroller, { deltaY: -120 })
|
||||
scroller.scrollTop = 650
|
||||
fireEvent.wheel(scroller, { deltaY: -120 })
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(650)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
settleAtBottom(scroller)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
// Chromium's compositor can update scrollTop before passive wheel
|
||||
// listeners run; the main-thread scroll event is still pending here.
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.wheel(scroller, { deltaY: -200 })
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('separates a small reader delta from layout growth before the gesture begins', () => {
|
||||
let notify: (() => void) | undefined
|
||||
class ResizeObserverStub {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
notify = () => { callback([], this as unknown as ResizeObserver) }
|
||||
}
|
||||
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const frames = installAnimationFrameQueue()
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
metrics.setHeight(1_040)
|
||||
fireEvent.wheel(scroller, { deltaY: -120 })
|
||||
scroller.scrollTop = 695
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(695)
|
||||
frames.flush()
|
||||
frames.flush()
|
||||
expect(scroller.scrollTop).toBe(740)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat an active-gesture tail clamp as reader movement', () => {
|
||||
const frames = installAnimationFrameQueue()
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
settleAtBottom(scroller)
|
||||
|
||||
fireEvent.wheel(scroller, { deltaY: -5 })
|
||||
scroller.scrollTop = 695
|
||||
fireEvent.scroll(scroller)
|
||||
metrics.setHeight(800) // floor 500 clamps the active gesture's old top
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
|
||||
scroller.scrollTop = 400
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
metrics.setHeight(600) // floor 300 clamps while reading in the same gesture
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
metrics.setHeight(200) // a non-scrollable floor clamps to zero, never negative
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
frames.flush()
|
||||
frames.flush()
|
||||
})
|
||||
|
||||
it('does not arm transcript ownership while a nested overflow consumes the wheel', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
const nested = document.createElement('div')
|
||||
nested.style.overflowY = 'auto'
|
||||
Object.defineProperty(nested, 'scrollHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(nested, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(nested, 'scrollTop', { value: 200, writable: true, configurable: true })
|
||||
scroller.appendChild(nested)
|
||||
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.wheel(nested, { deltaY: -120 })
|
||||
metrics.setLayout(1_000, 500)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(700)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
|
||||
nested.scrollTop = 0
|
||||
fireEvent.wheel(nested, { deltaY: -120 })
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(500)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
|
||||
nested.scrollTop = 300
|
||||
nested.addEventListener('wheel', (event) => {
|
||||
event.preventDefault()
|
||||
scroller.scrollTop = 700
|
||||
}, { once: true })
|
||||
fireEvent.wheel(nested, { deltaY: 120 })
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(700)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses host navigation keys without stealing editing keys', async () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
const button = document.createElement('button')
|
||||
const link = document.createElement('a')
|
||||
link.href = '#target'
|
||||
const textarea = document.createElement('textarea')
|
||||
scroller.append(button, link, textarea)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
fireEvent.keyDown(button, { key: 'PageUp' })
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
fireEvent.keyDown(link, { key: ' ', shiftKey: true })
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'ArrowUp' })
|
||||
metrics.setLayout(1_000, 500)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(700)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'PageUp' })
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Tab' })
|
||||
// Chromium runs microtasks before the default Tab focus transition. The
|
||||
// provenance must survive that checkpoint until focusin consumes it.
|
||||
await Promise.resolve()
|
||||
fireEvent.focusIn(button)
|
||||
scroller.scrollTop = 500
|
||||
fireEvent.scroll(scroller)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
|
||||
let notify: (() => void) | undefined
|
||||
const observe = vi.fn()
|
||||
@@ -1099,28 +856,16 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const frames = installAnimationFrameQueue()
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
settleAtBottom(scroller)
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
// re-render from setAtBottom must not force scrollTop to scrollHeight.
|
||||
readerScroll(scroller, 690) // distance-to-bottom = 10
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
frames.flush()
|
||||
frames.flush()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
const button = document.createElement('button')
|
||||
scroller.appendChild(button)
|
||||
fireEvent.keyDown(button, { key: 'Tab' })
|
||||
fireEvent.focusIn(button) // visible focus target: no host scroll follows
|
||||
frames.flush()
|
||||
frames.flush()
|
||||
expect(scroller.scrollTop).toBe(690)
|
||||
})
|
||||
|
||||
it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
|
||||
@@ -1174,8 +919,6 @@ describe('ChatView', () => {
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
|
||||
fireEvent.scroll(host) // delayed semantic-restore event remains away from bottom
|
||||
expect(host.scrollTop).toBe(580)
|
||||
// The restored position is above the floor: follow stays disarmed.
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user