fix(web): narrow chat scroll provenance

This commit is contained in:
kingwl
2026-08-02 18:06:15 +08:00
committed by imccyu
parent 8de33e42f7
commit 9b91e5312c
5 changed files with 97 additions and 669 deletions
@@ -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 {