|
|
@@ -2387,6 +2387,128 @@ describe('ChatView', () => {
|
|
|
expect(scroller.scrollTop).toBe(900)
|
|
|
})
|
|
|
|
|
|
+ it('keeps following when a shrink clamp regrows before scrollend', () => {
|
|
|
+ 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)
|
|
|
+ fireEvent(scroller, new Event('scrollend'))
|
|
|
+
|
|
|
+ metrics.setLayout(800, 700)
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ metrics.setHeight(962)
|
|
|
+ act(() => { h.setSession({ running: true }) })
|
|
|
+ fireEvent(scroller, new Event('scrollend'))
|
|
|
+
|
|
|
+ expect(scroller.scrollTop).toBe(662)
|
|
|
+ expect(view.queryByLabelText('回到底部')).toBeNull()
|
|
|
+ expect(h.chatScroll.read()).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('settles pinned deliveries before observer growth without reading row geometry', () => {
|
|
|
+ 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, 9_931, 300)
|
|
|
+ expect(notify).toBeDefined()
|
|
|
+ scroller.scrollTop = 9_631
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ fireEvent(scroller, new Event('scrollend'))
|
|
|
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
|
+ rect.mockClear()
|
|
|
+ try {
|
|
|
+ metrics.setLayout(9_918, 9_631)
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ metrics.setHeight(10_013)
|
|
|
+ act(() => { notify?.() })
|
|
|
+ expect(scroller.scrollTop).toBe(9_713)
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ metrics.setHeight(10_093)
|
|
|
+ act(() => { notify?.() })
|
|
|
+ expect(scroller.scrollTop).toBe(9_793)
|
|
|
+ expect(rect).not.toHaveBeenCalled()
|
|
|
+ expect(h.chatScroll.read()).toBeNull()
|
|
|
+ } finally {
|
|
|
+ rect.mockRestore()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
|
|
|
+ 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)
|
|
|
+ scroller.scrollTop = 500
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ fireEvent(scroller, new Event('scrollend'))
|
|
|
+ scroller.scrollTop = 400
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ fireEvent.click(view.getByLabelText('回到底部'))
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ metrics.setHeight(1_200)
|
|
|
+ act(() => { h.setSession({ running: true }) })
|
|
|
+ expect(scroller.scrollTop).toBe(900)
|
|
|
+ expect(h.chatScroll.read()).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('samples away-reader geometry on the interval or scrollend and cancels it on unmount', () => {
|
|
|
+ vi.useFakeTimers()
|
|
|
+ try {
|
|
|
+ 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)
|
|
|
+ scroller.scrollTop = 500
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ expect(view.getByLabelText('回到底部')).toBeTruthy()
|
|
|
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
|
+ try {
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ rect.mockClear()
|
|
|
+ scroller.scrollTop = 400
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ scroller.scrollTop = 300
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ act(() => { vi.advanceTimersByTime(499) })
|
|
|
+ expect(rect).not.toHaveBeenCalled()
|
|
|
+ act(() => { vi.advanceTimersByTime(1) })
|
|
|
+ expect(rect).toHaveBeenCalled()
|
|
|
+ rect.mockClear()
|
|
|
+ scroller.scrollTop = 200
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ expect(rect).not.toHaveBeenCalled()
|
|
|
+ fireEvent(scroller, new Event('scrollend'))
|
|
|
+ expect(rect).toHaveBeenCalled()
|
|
|
+ scroller.scrollTop = 100
|
|
|
+ fireEvent.scroll(scroller)
|
|
|
+ view.unmount()
|
|
|
+ rect.mockClear()
|
|
|
+ act(() => { vi.advanceTimersByTime(500) })
|
|
|
+ expect(rect).not.toHaveBeenCalled()
|
|
|
+ } finally {
|
|
|
+ rect.mockRestore()
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ vi.useRealTimers()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
|
|
|
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
|
|
const view = render(<h.ChatView {...h.props} />)
|