fix(web): preserve chat scroll ownership
This commit is contained in:
@@ -38,6 +38,68 @@ import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
const POINTER_CANCEL_GRACE_FRAMES = 8
|
||||
|
||||
type VerticalDirection = -1 | 0 | 1
|
||||
|
||||
interface ReaderGesture {
|
||||
/** Reader-created distance from the followed floor, excluding layout growth. */
|
||||
awayDistance: number
|
||||
/** Incremented by each qualifying input or host movement. */
|
||||
epoch: number
|
||||
/** Pointer gestures remain live through their pressed phase. */
|
||||
held: boolean
|
||||
/** Latest host position, used to reject no-op input events. */
|
||||
lastTop: number
|
||||
/** Primary pointer that owns a held direct-manipulation gesture. */
|
||||
pointerId: number | null
|
||||
}
|
||||
|
||||
/** Whether this element can consume a vertical gesture in the given direction. */
|
||||
function canScrollVertically(element: HTMLElement, direction: VerticalDirection): boolean {
|
||||
if (direction === 0) return element.scrollHeight > element.clientHeight + 1
|
||||
if (direction < 0) return element.scrollTop > 1
|
||||
return element.scrollTop + element.clientHeight < element.scrollHeight - 1
|
||||
}
|
||||
|
||||
/** A nested overflow owner keeps wheel/key input away from the transcript. */
|
||||
function nestedOwnsVerticalScroll(
|
||||
target: EventTarget | null,
|
||||
scrollport: HTMLElement,
|
||||
direction: VerticalDirection,
|
||||
): boolean {
|
||||
let current = target instanceof HTMLElement ? target : null
|
||||
while (current !== null && current !== scrollport) {
|
||||
const style = getComputedStyle(current)
|
||||
const scrollable = style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflowY === 'overlay'
|
||||
if (scrollable && current.scrollHeight > current.clientHeight + 1) {
|
||||
if (canScrollVertically(current, direction)) return true
|
||||
if (style.overscrollBehaviorY === 'contain' || style.overscrollBehaviorY === 'none') return true
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Vertical scrolling keys and their expected host direction. */
|
||||
function keyDirection(event: globalThis.KeyboardEvent): VerticalDirection | null {
|
||||
if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') return -1
|
||||
if (event.key === 'ArrowDown' || event.key === 'PageDown' || event.key === 'End') return 1
|
||||
if (event.key === ' ' || event.key === 'Spacebar') return event.shiftKey ? -1 : 1
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether the focused control, rather than its scroll ancestor, owns this key. */
|
||||
function consumesScrollKey(event: globalThis.KeyboardEvent): boolean {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
const editable = target.isContentEditable || target.closest('input, textarea, select') !== null
|
||||
if (event.key === ' ' || event.key === 'Spacebar') {
|
||||
return editable || target.closest('button, [role="button"], [role="menuitem"]') !== null
|
||||
}
|
||||
if (event.key === 'PageUp' || event.key === 'PageDown') return false
|
||||
return editable || target.closest('[role="tab"], [role="menuitem"], [role="listbox"], [role="option"], [role="slider"], [role="spinbutton"]') !== null
|
||||
}
|
||||
|
||||
/** Active column host when present; otherwise the view-local scroller. */
|
||||
function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
@@ -335,8 +397,19 @@ export function ChatView({
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Reader-owned follow intent, distinct from transient bottom geometry. */
|
||||
const bottomOwnedRef = useRef(true)
|
||||
const [bottomOwned, setBottomOwned] = useState(true)
|
||||
/** Distance created by reader movement while follow ownership is retained. */
|
||||
const readerAwayRef = useRef(0)
|
||||
/** Last scrollTop delivered or written on the main thread. */
|
||||
const observedTopRef = useRef(0)
|
||||
/** Only an observable reader input may let scroll geometry change follow intent. */
|
||||
const readerGestureRef = useRef<ReaderGesture | null>(null)
|
||||
const finishReaderGestureRef = useRef<(gesture: ReaderGesture) => void>(() => {})
|
||||
const scheduleReaderGestureEndRef = useRef<(gesture: ReaderGesture) => void>(() => {})
|
||||
const schedulePointerCancelEndRef = useRef<(gesture: ReaderGesture) => void>(() => {})
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
/** Paging anchor: semantic row/position at click, updated by reader scrolls
|
||||
* while the request is pending and restored after the prepend lands. */
|
||||
const anchorRef = useRef<PagingAnchor | null>(null)
|
||||
@@ -355,14 +428,117 @@ export function ChatView({
|
||||
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
anchorRef.current = null
|
||||
const writeBottom = (el: HTMLElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
observedTopRef.current = el.scrollTop
|
||||
readerAwayRef.current = 0
|
||||
chatScroll.save(null)
|
||||
}
|
||||
|
||||
const cancelReaderGesture = (): void => {
|
||||
readerGestureRef.current = null
|
||||
}
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
cancelReaderGesture()
|
||||
anchorRef.current = null
|
||||
bottomOwnedRef.current = true
|
||||
setBottomOwned(true)
|
||||
writeBottom(el)
|
||||
}
|
||||
|
||||
const recordReaderPosition = (local: HTMLElement, el: HTMLElement, ownsBottom?: boolean): boolean => {
|
||||
const nextOwnsBottom = ownsBottom
|
||||
?? el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
bottomOwnedRef.current = nextOwnsBottom
|
||||
setBottomOwned(nextOwnsBottom)
|
||||
const position = nextOwnsBottom ? null : scrollPosition(local, el)
|
||||
if (nextOwnsBottom) {
|
||||
anchorRef.current = null
|
||||
chatScroll.save(null)
|
||||
} else {
|
||||
if (anchorRef.current !== null && position !== null) {
|
||||
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
|
||||
}
|
||||
if (position !== null) chatScroll.save(position)
|
||||
}
|
||||
return nextOwnsBottom
|
||||
}
|
||||
|
||||
finishReaderGestureRef.current = (gesture) => {
|
||||
if (readerGestureRef.current !== gesture) return
|
||||
readerGestureRef.current = null
|
||||
const local = listRef.current
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const physicalAway = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight)
|
||||
if (bottomOwnedRef.current && Math.abs(physicalAway - readerAwayRef.current) > 1) {
|
||||
followRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
// A wheel tick, smooth keyboard scroll, or touch fling can span multiple
|
||||
// scroll events. End only after two animation frames without new movement;
|
||||
// a held pointer keeps the gesture alive across slower frames.
|
||||
scheduleReaderGestureEndRef.current = (gesture) => {
|
||||
const epoch = gesture.epoch
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (readerGestureRef.current === gesture && gesture.epoch === epoch && !gesture.held) {
|
||||
finishReaderGestureRef.current(gesture)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
schedulePointerCancelEndRef.current = (gesture) => {
|
||||
const epoch = gesture.epoch
|
||||
let frames = POINTER_CANCEL_GRACE_FRAMES
|
||||
const waitForScroll = (): void => {
|
||||
requestAnimationFrame(() => {
|
||||
if (readerGestureRef.current !== gesture || gesture.epoch !== epoch) return
|
||||
frames -= 1
|
||||
if (frames > 0) waitForScroll()
|
||||
else finishReaderGestureRef.current(gesture)
|
||||
})
|
||||
}
|
||||
waitForScroll()
|
||||
}
|
||||
|
||||
const armReaderGesture = (
|
||||
startTop: number,
|
||||
startBottom: number,
|
||||
held = false,
|
||||
pointerId: number | null = null,
|
||||
): ReaderGesture => {
|
||||
const current = readerGestureRef.current
|
||||
const persistentAway = bottomOwnedRef.current
|
||||
? readerAwayRef.current
|
||||
: Math.max(0, startBottom - startTop)
|
||||
const gesture = current ?? {
|
||||
awayDistance: persistentAway,
|
||||
epoch: 0,
|
||||
held: false,
|
||||
lastTop: startTop,
|
||||
pointerId: null,
|
||||
}
|
||||
gesture.epoch += 1
|
||||
if (held) {
|
||||
gesture.held = true
|
||||
gesture.pointerId = pointerId
|
||||
}
|
||||
readerGestureRef.current = gesture
|
||||
scheduleReaderGestureEndRef.current(gesture)
|
||||
return gesture
|
||||
}
|
||||
|
||||
followRef.current = () => {
|
||||
const local = listRef.current
|
||||
if (local !== null && bottomOwnedRef.current && readerGestureRef.current === null) {
|
||||
writeBottom(scrollerOf(local))
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
@@ -377,15 +553,21 @@ export function ChatView({
|
||||
if (saved === null) {
|
||||
toBottom(el)
|
||||
} else {
|
||||
cancelReaderGesture()
|
||||
el.scrollTop = saved.scrollTop
|
||||
const row = anchorElement(local, saved.anchorKey)
|
||||
if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
const normalized = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) chatScroll.save(null)
|
||||
else if (normalized !== null) chatScroll.save(normalized)
|
||||
if (isAtBottom) {
|
||||
toBottom(el)
|
||||
} else {
|
||||
observedTopRef.current = el.scrollTop
|
||||
readerAwayRef.current = 0
|
||||
bottomOwnedRef.current = false
|
||||
setBottomOwned(false)
|
||||
const normalized = scrollPosition(local, el)
|
||||
if (normalized !== null) chatScroll.save(normalized)
|
||||
}
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
@@ -400,7 +582,16 @@ export function ChatView({
|
||||
const anchor = anchorRef.current
|
||||
anchorRef.current = null
|
||||
const row = anchorElement(local, anchor.key)
|
||||
cancelReaderGesture()
|
||||
if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
|
||||
observedTopRef.current = el.scrollTop
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
readerAwayRef.current = 0
|
||||
bottomOwnedRef.current = isAtBottom
|
||||
setBottomOwned(isAtBottom)
|
||||
const normalized = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) chatScroll.save(null)
|
||||
else if (normalized !== null) chatScroll.save(normalized)
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastKey
|
||||
@@ -419,8 +610,9 @@ export function ChatView({
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
// merely because bottomOwnedRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || appendedSteering
|
||||
|| (tipMoved && bottomOwnedRef.current && readerGestureRef.current === null)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScrollRef = useRef(() => {})
|
||||
@@ -429,43 +621,210 @@ export function ChatView({
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
const position = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) {
|
||||
anchorRef.current = null
|
||||
} else if (anchorRef.current !== null && position !== null) {
|
||||
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
|
||||
const gesture = readerGestureRef.current
|
||||
if (gesture !== null) {
|
||||
const previousTop = gesture.lastTop
|
||||
// A tail shrink can clamp the old scrollTop to the new physical floor
|
||||
// before its scroll event arrives. Exclude that forced portion while
|
||||
// retaining any reader movement beyond the clamp.
|
||||
const currentFloor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const baselineTop = Math.min(previousTop, currentFloor)
|
||||
const deltaTop = el.scrollTop - baselineTop
|
||||
gesture.lastTop = el.scrollTop
|
||||
if (Math.abs(deltaTop) > 0.5) {
|
||||
gesture.awayDistance = Math.max(0, gesture.awayDistance - deltaTop)
|
||||
if (bottomOwnedRef.current) {
|
||||
readerAwayRef.current = gesture.awayDistance
|
||||
if (!recordReaderPosition(local, el, gesture.awayDistance <= FOLLOW_THRESHOLD + 1)) {
|
||||
readerAwayRef.current = 0
|
||||
}
|
||||
} else {
|
||||
// Only reader motion toward the floor may reclaim follow ownership;
|
||||
// shrink/clamp or an upward gesture must preserve reading mode.
|
||||
const ownsBottom = recordReaderPosition(local, el, deltaTop > 0
|
||||
? undefined
|
||||
: false)
|
||||
if (ownsBottom) {
|
||||
const physicalAway = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight)
|
||||
gesture.awayDistance = physicalAway
|
||||
readerAwayRef.current = physicalAway
|
||||
} else {
|
||||
readerAwayRef.current = 0
|
||||
}
|
||||
}
|
||||
} else if (!bottomOwnedRef.current && Math.abs(el.scrollTop - previousTop) > 0.5) {
|
||||
recordReaderPosition(local, el, false)
|
||||
}
|
||||
gesture.epoch += 1
|
||||
scheduleReaderGestureEndRef.current(gesture)
|
||||
observedTopRef.current = el.scrollTop
|
||||
return
|
||||
}
|
||||
// Continuous save (unmount happens after ref detach, so saving there is
|
||||
// too late); pinned-to-bottom clears so a remount keeps following.
|
||||
if (isAtBottom) chatScroll.save(null)
|
||||
else if (position !== null) chatScroll.save(position)
|
||||
|
||||
if (bottomOwnedRef.current) {
|
||||
anchorRef.current = null
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
if (Math.abs(distance) > 1) writeBottom(el)
|
||||
else {
|
||||
readerAwayRef.current = 0
|
||||
chatScroll.save(null)
|
||||
}
|
||||
observedTopRef.current = el.scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
// A layout/programmatic event while reading may update the saved semantic
|
||||
// position, but cannot silently reclaim or release bottom ownership.
|
||||
const position = scrollPosition(local, el)
|
||||
if (position !== null) chatScroll.save(position)
|
||||
observedTopRef.current = el.scrollTop
|
||||
}
|
||||
|
||||
// Bind scroll to the resolved scrollport (host or local) once per mount.
|
||||
// Bind scroll and its reader-input provenance to the resolved scrollport.
|
||||
useEffect(() => {
|
||||
const local = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
const onScroll = (): void => { onScrollRef.current() }
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => { el.removeEventListener('scroll', onScroll) }
|
||||
}, [])
|
||||
let wheelStartTop = el.scrollTop
|
||||
let wheelStartBottom = el.scrollHeight - el.clientHeight
|
||||
let wheelCanMoveHost = false
|
||||
let directPointer: {
|
||||
id: number
|
||||
lastY: number
|
||||
startBottom: number
|
||||
startTop: number
|
||||
target: EventTarget | null
|
||||
} | null = null
|
||||
let tabStart: { bottom: number; top: number } | null = null
|
||||
let tabClearTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// The ref starts null and is assigned every render, so the placeholder
|
||||
// initializer a function initial value would need never exists.
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
followRef.current = () => {
|
||||
const local = listRef.current
|
||||
if (local !== null && atBottomRef.current) {
|
||||
const el = scrollerOf(local)
|
||||
el.scrollTop = el.scrollHeight
|
||||
chatScroll.save(null)
|
||||
const clearTabStart = (): void => {
|
||||
tabStart = null
|
||||
if (tabClearTimer !== null) {
|
||||
clearTimeout(tabClearTimer)
|
||||
tabClearTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onScroll = (): void => { onScrollRef.current() }
|
||||
const onWheelCapture = (event: WheelEvent): void => {
|
||||
// Passive wheel delivery may observe compositor-updated geometry before
|
||||
// the main-thread scroll event. The last delivered/written top remains
|
||||
// the authoritative pre-input baseline.
|
||||
wheelStartTop = observedTopRef.current
|
||||
wheelStartBottom = el.scrollHeight - el.clientHeight
|
||||
const direction: VerticalDirection = event.deltaY < 0 ? -1 : 1
|
||||
wheelCanMoveHost = event.deltaY !== 0 && (direction < 0
|
||||
? wheelStartTop > 1
|
||||
: wheelStartTop < wheelStartBottom - 1)
|
||||
}
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.ctrlKey || event.deltaY === 0) return
|
||||
const direction: VerticalDirection = event.deltaY < 0 ? -1 : 1
|
||||
if (!wheelCanMoveHost) return
|
||||
if (nestedOwnsVerticalScroll(event.target, el, direction)) return
|
||||
armReaderGesture(wheelStartTop, wheelStartBottom)
|
||||
}
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Tab') {
|
||||
if (event.defaultPrevented) clearTabStart()
|
||||
return
|
||||
}
|
||||
if (event.defaultPrevented) return
|
||||
if (!(event.target instanceof Node) || !el.contains(event.target)) return
|
||||
const direction = keyDirection(event)
|
||||
if (direction === null) return
|
||||
if (consumesScrollKey(event)) return
|
||||
if (!canScrollVertically(el, direction)) return
|
||||
if (nestedOwnsVerticalScroll(event.target, el, direction)) return
|
||||
armReaderGesture(el.scrollTop, el.scrollHeight - el.clientHeight)
|
||||
}
|
||||
const onTabKeyCapture = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return
|
||||
const start = { bottom: el.scrollHeight - el.clientHeight, top: el.scrollTop }
|
||||
tabStart = start
|
||||
if (tabClearTimer !== null) clearTimeout(tabClearTimer)
|
||||
// The browser's default Tab focus and focus-induced scroll happen after
|
||||
// keydown propagation but before the next task. A microtask would clear
|
||||
// this provenance before focusin can consume it.
|
||||
tabClearTimer = setTimeout(() => {
|
||||
if (tabStart === start) tabStart = null
|
||||
tabClearTimer = null
|
||||
}, 0)
|
||||
}
|
||||
const onFocusIn = (): void => {
|
||||
const start = tabStart
|
||||
clearTabStart()
|
||||
if (start !== null) armReaderGesture(start.top, start.bottom)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
if (!event.isPrimary) return
|
||||
if (event.pointerType === 'mouse') {
|
||||
// Native scrollbar/track events target the scrollport; ordinary flow
|
||||
// controls target their own element and must not pause follow.
|
||||
if (event.button === 0 && event.target === el) {
|
||||
armReaderGesture(el.scrollTop, el.scrollHeight - el.clientHeight, true, event.pointerId)
|
||||
}
|
||||
return
|
||||
}
|
||||
directPointer = {
|
||||
id: event.pointerId,
|
||||
lastY: event.clientY,
|
||||
startBottom: el.scrollHeight - el.clientHeight,
|
||||
startTop: el.scrollTop,
|
||||
target: event.target,
|
||||
}
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
const pointer = directPointer
|
||||
if (pointer === null || event.pointerId !== pointer.id) return
|
||||
const delta = pointer.lastY - event.clientY
|
||||
pointer.lastY = event.clientY
|
||||
if (Math.abs(delta) < 2) return
|
||||
const direction: VerticalDirection = delta < 0 ? -1 : 1
|
||||
if (!canScrollVertically(el, direction)) return
|
||||
if (nestedOwnsVerticalScroll(pointer.target, el, direction)) return
|
||||
armReaderGesture(pointer.startTop, pointer.startBottom, true, pointer.id)
|
||||
}
|
||||
const releasePointer = (event: PointerEvent, cancelled: boolean): void => {
|
||||
if (!event.isPrimary) return
|
||||
if (directPointer?.id === event.pointerId) directPointer = null
|
||||
const gesture = readerGestureRef.current
|
||||
if (gesture === null || !gesture.held || gesture.pointerId !== event.pointerId) return
|
||||
gesture.held = false
|
||||
gesture.pointerId = null
|
||||
gesture.epoch += 1
|
||||
if (cancelled) schedulePointerCancelEndRef.current(gesture)
|
||||
else scheduleReaderGestureEndRef.current(gesture)
|
||||
}
|
||||
const onPointerUp = (event: PointerEvent): void => { releasePointer(event, false) }
|
||||
const onPointerCancel = (event: PointerEvent): void => { releasePointer(event, true) }
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheelCapture, { capture: true, passive: true })
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('focusin', onFocusIn)
|
||||
el.addEventListener('pointerdown', onPointerDown, { passive: true })
|
||||
window.addEventListener('keydown', onTabKeyCapture, { capture: true })
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||||
window.addEventListener('pointerup', onPointerUp, { passive: true })
|
||||
window.addEventListener('pointercancel', onPointerCancel, { passive: true })
|
||||
return () => {
|
||||
cancelReaderGesture()
|
||||
clearTabStart()
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheelCapture, true)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('focusin', onFocusIn)
|
||||
el.removeEventListener('pointerdown', onPointerDown)
|
||||
window.removeEventListener('keydown', onTabKeyCapture, true)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
}
|
||||
}, [])
|
||||
// Streaming, tool disclosures, and other flow changes resize the column;
|
||||
// the sticky composer resizes outside it. This observer owns ChatView's
|
||||
// dynamic-height follow decisions and writes only while the reader is pinned.
|
||||
@@ -494,6 +853,7 @@ export function ChatView({
|
||||
const el = scrollerOf(local)
|
||||
const row = pagingAnchor(local, el)
|
||||
if (row !== null && row.dataset.chatAnchorKey !== undefined) {
|
||||
cancelReaderGesture()
|
||||
anchorRef.current = {
|
||||
key: row.dataset.chatAnchorKey,
|
||||
top: flowTop(row, el),
|
||||
@@ -611,7 +971,7 @@ export function ChatView({
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
{!bottomOwned && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -157,6 +157,56 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
|
||||
}
|
||||
|
||||
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
|
||||
let scrollHeight = initialHeight
|
||||
let scrollTop = 0
|
||||
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
|
||||
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
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))
|
||||
},
|
||||
setLayout: (height: number, top: number) => {
|
||||
scrollHeight = height
|
||||
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 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[] = [
|
||||
@@ -269,15 +319,14 @@ describe('ChatView', () => {
|
||||
)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 50
|
||||
fireEvent.scroll(scroller)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// The reader moves after the request starts; this, not the click-time
|
||||
// row, is the intent the arriving page must preserve.
|
||||
firstTop = -200
|
||||
nextTop = 60
|
||||
scroller.scrollTop = 90
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 90)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
nextTop = 560
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
|
||||
@@ -662,19 +711,21 @@ 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(
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
)
|
||||
scroller.scrollTop = 80
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 80)
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
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)
|
||||
@@ -695,8 +746,8 @@ describe('ChatView', () => {
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 80
|
||||
fireEvent.scroll(scroller)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
@@ -723,8 +774,8 @@ describe('ChatView', () => {
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 80
|
||||
fireEvent.scroll(scroller)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
@@ -742,8 +793,8 @@ 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 })
|
||||
scroller.scrollTop = 50
|
||||
fireEvent.scroll(scroller)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
@@ -758,8 +809,8 @@ 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 })
|
||||
scroller.scrollTop = 100 // far from bottom
|
||||
fireEvent.scroll(scroller)
|
||||
settleAtBottom(scroller)
|
||||
readerScroll(scroller, 100) // far from bottom
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
@@ -771,6 +822,253 @@ 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)
|
||||
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
|
||||
|
||||
// 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
|
||||
metrics.setLayout(1_040, 500)
|
||||
fireEvent.scroll(scroller)
|
||||
expect(scroller.scrollTop).toBe(740)
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
metrics.setHeight(1_200)
|
||||
act(() => { notify?.() })
|
||||
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)
|
||||
|
||||
// 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()
|
||||
@@ -793,8 +1091,7 @@ describe('ChatView', () => {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(1_200)
|
||||
scroller.scrollTop = 200
|
||||
fireEvent.scroll(scroller)
|
||||
readerScroll(scroller, 200)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
|
||||
act(() => { notify?.() })
|
||||
expect(scroller.scrollTop).toBe(200)
|
||||
@@ -802,17 +1099,28 @@ 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.
|
||||
scroller.scrollTop = 690 // distance-to-bottom = 10
|
||||
fireEvent.scroll(scroller)
|
||||
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', () => {
|
||||
@@ -827,8 +1135,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
// Open jump uses the host, not the local .scroll node.
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
readerScroll(host, 100)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
@@ -860,14 +1167,15 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(2000)
|
||||
// Reader scrolls up; the position is recorded continuously.
|
||||
host.scrollTop = 100
|
||||
fireEvent.scroll(host)
|
||||
readerScroll(host, 100)
|
||||
// View-tab switch away and back: the view unmounts, then remounts.
|
||||
view.rerender(<div />)
|
||||
anchorTop = 560
|
||||
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