mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): narrow chat scroll provenance
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: f7cc843f322967ac6a66457aee237bc74174263d
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: 22d59294e064beacf86680c4b45355a51f47213e
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5
|
||||
|
||||
@@ -14,7 +14,7 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow
|
||||
|
||||
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
|
||||
|
||||
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is reader-owned intent, distinct from instantaneous geometry: raw scroll events never change ownership because programmatic writes, browser clamping or anchoring, and reader movement share the same event shape. `ChatView` arms a reader gesture from wheel/trackpad input that reaches the host, direct pointer or touch scrolling, native-scrollport pointer input, and vertical or focus-navigation keys; a nested overflow owner that can consume the direction does not arm the transcript. Wheel handling remains passive and takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering a passive wheel event. Only host movement during that gesture may transition between following and reading, and two idle animation frames end it; a new tail floor excludes its forced clamp from reader movement, and reading mode can reclaim follow only on reader movement toward the floor. A non-reader scroll drift re-pins while following and only refreshes the semantic saved position while reading. Semantic history restore and prepend correction cancel any active reader gesture before applying and saving their explicit ownership state. ChatView's single `ResizeObserver` owns bottom-follow decisions for column and sticky-composer height changes: it follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned and no reader gesture is active, without a second per-chunk scroll write.
|
||||
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,8 +26,8 @@ Chat history prepend follows reader intent through stable rendered node/call ide
|
||||
|
||||
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
|
||||
|
||||
**Ignore or target-match the next scroll event after a programmatic bottom write.** Rejected: no-op writes may emit no event, repeated writes may coalesce, and stream finalization can shrink-clamp then regrow the scrollport before one delayed event reports a position different from the last target. That event is indistinguishable from reader movement without input provenance.
|
||||
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine.
|
||||
|
||||
## Consequences
|
||||
|
||||
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve the reader's newer scroll decision; outside an active reader gesture, delayed programmatic scroll events cannot change follow ownership. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
|
||||
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
|
||||
|
||||
Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随的意图归读者掌控,与瞬时几何状态相互独立:原始滚动事件绝不改变所有权,因为程序化写入、浏览器执行的钳制或锚定,以及读者滚动,都会产生形态相同的事件。`ChatView` 会根据以下来源激活读者手势:抵达宿主的滚轮/触控板输入、直接通过指针或触控进行的滚动、原生滚动容器自身收到的指针输入,以及纵向滚动键或焦点导航键;嵌套 overflow owner 若能沿该方向滚动,就不会为 transcript 激活手势。滚轮处理保持 passive,并以主线程最近一次收到滚动事件或程序化写入后的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进 compositor 几何状态,再投递 passive wheel 事件。只有该手势期间的宿主滚动才能在跟随与阅读之间切换;连续两个空闲的动画帧后,手势结束;新的末端高度会从读者位移中排除其强制钳制量,而阅读状态只有在读者朝底部移动时才能重新取得跟随所有权。并非由读者引起的滚动漂移在跟随状态下会重新贴底,在阅读状态下则只刷新保存的语义位置。语义化历史位置恢复与前插校正会先取消任何活跃的读者手势,再应用并保存各自明确的所有权状态。`ChatView` 的单个 `ResizeObserver` 统一负责消息列与 sticky 编辑器高度变化的贴底跟随决策:只有在贴底所有权仍保持且没有活跃的读者手势时才跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
|
||||
Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,8 +26,8 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
|
||||
|
||||
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
|
||||
|
||||
**程序化贴底写入后,忽略下一个滚动事件或按目标位置匹配该事件。** 否决:未改变位置的写入可能不触发事件,重复写入可能合并;流收尾阶段还可能先让滚动容器收缩并钳制位置,再使其重新增长,最后由一个延迟事件报告不同于上一目标的位置。若没有输入来源信息,该事件与读者滚动无法区分。
|
||||
**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。
|
||||
|
||||
## Consequences
|
||||
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者更新的滚动决定;在没有活跃读者手势时,延迟到达的程序化滚动事件不能改变跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
|
||||
|
||||
@@ -38,68 +38,6 @@ 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 {
|
||||
@@ -397,19 +335,13 @@ export function ChatView({
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
/** 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 atBottomRef = useRef(true)
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Last position 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)
|
||||
/** Pre-input position for the current wheel gesture. */
|
||||
const wheelStartRef = useRef<number | null>(null)
|
||||
const wheelEpochRef = useRef(0)
|
||||
/** 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)
|
||||
@@ -428,117 +360,17 @@ 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 writeBottom = (el: HTMLElement): void => {
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
wheelStartRef.current = null
|
||||
wheelEpochRef.current += 1
|
||||
anchorRef.current = null
|
||||
el.scrollTop = el.scrollHeight
|
||||
observedTopRef.current = el.scrollTop
|
||||
readerAwayRef.current = 0
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
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. */
|
||||
@@ -553,21 +385,16 @@ 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
|
||||
observedTopRef.current = el.scrollTop
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
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)
|
||||
}
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
const normalized = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) chatScroll.save(null)
|
||||
else if (normalized !== null) chatScroll.save(normalized)
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
@@ -582,16 +409,8 @@ 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
|
||||
@@ -610,9 +429,8 @@ export function ChatView({
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because bottomOwnedRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || appendedSteering
|
||||
|| (tipMoved && bottomOwnedRef.current && readerGestureRef.current === null)) toBottom(el)
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScrollRef = useRef(() => {})
|
||||
@@ -621,210 +439,78 @@ export function ChatView({
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
|
||||
if (local === null) return
|
||||
const el = scrollerOf(local)
|
||||
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
|
||||
// Only wheel input may make raw scroll geometry change follow ownership.
|
||||
// Browser clamping and delayed programmatic scroll events otherwise have
|
||||
// the same event shape and must preserve the current ownership state.
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const wheelStart = wheelStartRef.current
|
||||
const movedByWheel = wheelStart !== null
|
||||
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
|
||||
const isAtBottom = movedByWheel
|
||||
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
|
||||
: atBottomRef.current
|
||||
if (!movedByWheel && isAtBottom) {
|
||||
toBottom(el)
|
||||
return
|
||||
}
|
||||
|
||||
if (bottomOwnedRef.current) {
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
const position = isAtBottom ? null : scrollPosition(local, el)
|
||||
if (isAtBottom) {
|
||||
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
|
||||
} else if (anchorRef.current !== null && position !== null) {
|
||||
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
observedTopRef.current = el.scrollTop
|
||||
}
|
||||
|
||||
// Bind scroll and its reader-input provenance to the resolved scrollport.
|
||||
// Bind scroll and the wheel provenance needed to distinguish reader input
|
||||
// from layout-driven scrolls on the resolved scrollport once per mount.
|
||||
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)
|
||||
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
|
||||
|
||||
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 startTop = observedTopRef.current
|
||||
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
|
||||
if (!canMove) return
|
||||
wheelStartRef.current = startTop
|
||||
const epoch = ++wheelEpochRef.current
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
|
||||
})
|
||||
})
|
||||
}
|
||||
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 })
|
||||
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
return () => {
|
||||
cancelReaderGesture()
|
||||
clearTabStart()
|
||||
wheelStartRef.current = null
|
||||
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)
|
||||
el.removeEventListener('wheel', onWheel, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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
|
||||
observedTopRef.current = el.scrollTop
|
||||
chatScroll.save(null)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -853,7 +539,6 @@ 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),
|
||||
@@ -971,7 +656,7 @@ export function ChatView({
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!bottomOwned && (
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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