mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix(chat): redesign rehydration scroll lifecycle
Redesigns chat rehydration scroll lifecycle so existing chats consistently converge to the newest message on open while preserving explicit user control. - Introduce an explicit phase model in ChatView for hydration, anchored following, and user-history browsing states - Replace ad-hoc settle loops with a bounded initial settle window using frame and time caps - Re-anchoring now uses deterministic phase transitions instead of race-prone sticky toggles - Upward user intent (wheel, keyboard navigation, or pointer/manual upward scroll) immediately disengages automatic follow - Scroll-to-bottom CTA re-anchors in one interaction - Settle loop respects user scroll intent via stickyFollowRef gating - Explicit escape hatches cover keyboard nav, pointer drag, wheel, and row expansion scroll-away methods Squashed from scroll-fix branch (PR #11483).
This commit is contained in:
parent
3e24e21c3d
commit
573da7f2dd
3 changed files with 818 additions and 47 deletions
|
|
@ -143,11 +143,12 @@ const ChatRow = memo(
|
|||
)
|
||||
|
||||
useEffect(() => {
|
||||
const isHeightValid = height !== 0 && height !== Infinity
|
||||
// used for partials, command output, etc.
|
||||
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
|
||||
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
|
||||
// height starts off at Infinity
|
||||
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
|
||||
if (isLast && isHeightValid && height !== prevHeightRef.current) {
|
||||
if (!isInitialRender) {
|
||||
onHeightChange(height > prevHeightRef.current)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,8 +62,30 @@ export interface ChatViewRef {
|
|||
|
||||
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
|
||||
|
||||
const INITIAL_LOAD_SETTLE_TIMEOUT_MS = 2500
|
||||
const INITIAL_LOAD_SETTLE_HARD_CAP_MS = 10000
|
||||
const INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET = 3
|
||||
const INITIAL_LOAD_SETTLE_MAX_FRAMES = Math.ceil(INITIAL_LOAD_SETTLE_HARD_CAP_MS / (1000 / 60))
|
||||
|
||||
type ScrollPhase = "HYDRATING_PINNED_TO_BOTTOM" | "ANCHORED_FOLLOWING" | "USER_BROWSING_HISTORY"
|
||||
|
||||
type ScrollFollowDisengageSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up"
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||
|
||||
const isEditableKeyboardTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tagName = target.tagName
|
||||
return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
|
||||
}
|
||||
|
||||
const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
|
||||
{ isHidden, showAnnouncement, hideAnnouncement },
|
||||
ref,
|
||||
|
|
@ -156,9 +178,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
const prevExpandedRowsRef = useRef<Record<number, boolean>>()
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const stickyFollowRef = useRef<boolean>(false)
|
||||
const [scrollPhase, setScrollPhase] = useState<ScrollPhase>("USER_BROWSING_HISTORY")
|
||||
const scrollPhaseRef = useRef<ScrollPhase>("USER_BROWSING_HISTORY")
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const isAtBottomRef = useRef(false)
|
||||
const isSettlingRef = useRef(false)
|
||||
const settleTaskTsRef = useRef<number | null>(null)
|
||||
const settleDeadlineMsRef = useRef<number | null>(null)
|
||||
const settleHardDeadlineMsRef = useRef<number | null>(null)
|
||||
const settleAnimationFrameRef = useRef<number | null>(null)
|
||||
const settleStableFramesRef = useRef(0)
|
||||
const settleFrameCountRef = useRef(0)
|
||||
const settleBottomConfirmedRef = useRef(false)
|
||||
const settleMutationVersionRef = useRef(0)
|
||||
const settleObservedMutationVersionRef = useRef(0)
|
||||
const groupedMessagesLengthRef = useRef(0)
|
||||
const pointerScrollActiveRef = useRef(false)
|
||||
const pointerScrollLastTopRef = useRef<number | null>(null)
|
||||
const reanchorAnimationFrameRef = useRef<number | null>(null)
|
||||
const lastTtsRef = useRef<string>("")
|
||||
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
|
||||
const [checkpointWarning, setCheckpointWarning] = useState<
|
||||
|
|
@ -191,6 +228,174 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
clineAskRef.current = clineAsk
|
||||
}, [clineAsk])
|
||||
|
||||
useEffect(() => {
|
||||
scrollPhaseRef.current = scrollPhase
|
||||
}, [scrollPhase])
|
||||
|
||||
const transitionScrollPhase = useCallback((nextPhase: ScrollPhase) => {
|
||||
if (scrollPhaseRef.current === nextPhase) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollPhaseRef.current = nextPhase
|
||||
setScrollPhase(nextPhase)
|
||||
}, [])
|
||||
|
||||
const beginHydrationPinnedToBottom = useCallback(() => {
|
||||
isAtBottomRef.current = false
|
||||
settleBottomConfirmedRef.current = false
|
||||
settleFrameCountRef.current = 0
|
||||
transitionScrollPhase("HYDRATING_PINNED_TO_BOTTOM")
|
||||
setShowScrollToBottom(false)
|
||||
}, [transitionScrollPhase])
|
||||
|
||||
const enterAnchoredFollowing = useCallback(() => {
|
||||
transitionScrollPhase("ANCHORED_FOLLOWING")
|
||||
setShowScrollToBottom(false)
|
||||
}, [transitionScrollPhase])
|
||||
|
||||
const enterUserBrowsingHistory = useCallback(
|
||||
(_source: ScrollFollowDisengageSource) => {
|
||||
transitionScrollPhase("USER_BROWSING_HISTORY")
|
||||
setShowScrollToBottom(!isAtBottomRef.current)
|
||||
},
|
||||
[transitionScrollPhase],
|
||||
)
|
||||
|
||||
const isSettleWindowOpen = useCallback((taskTs: number): boolean => {
|
||||
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (settleTaskTsRef.current !== taskTs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nowMs = Date.now()
|
||||
const deadlineMs = settleDeadlineMsRef.current
|
||||
if (deadlineMs === null || nowMs > deadlineMs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hardDeadlineMs = settleHardDeadlineMsRef.current
|
||||
return hardDeadlineMs === null || nowMs <= hardDeadlineMs
|
||||
}, [])
|
||||
|
||||
const extendInitialSettleWindow = useCallback((taskTs: number): boolean => {
|
||||
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (settleTaskTsRef.current !== taskTs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nowMs = Date.now()
|
||||
const hardDeadlineMs = settleHardDeadlineMsRef.current
|
||||
if (hardDeadlineMs !== null && nowMs > hardDeadlineMs) {
|
||||
return false
|
||||
}
|
||||
|
||||
settleDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS
|
||||
if (hardDeadlineMs === null) {
|
||||
settleHardDeadlineMsRef.current = nowMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS
|
||||
}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const cancelInitialSettleFrame = useCallback(() => {
|
||||
if (settleAnimationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(settleAnimationFrameRef.current)
|
||||
settleAnimationFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancelReanchorFrame = useCallback(() => {
|
||||
if (reanchorAnimationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(reanchorAnimationFrameRef.current)
|
||||
reanchorAnimationFrameRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const completeInitialSettle = useCallback(() => {
|
||||
cancelInitialSettleFrame()
|
||||
isSettlingRef.current = false
|
||||
if (isAtBottomRef.current && settleBottomConfirmedRef.current) {
|
||||
enterAnchoredFollowing()
|
||||
return
|
||||
}
|
||||
|
||||
transitionScrollPhase("USER_BROWSING_HISTORY")
|
||||
setShowScrollToBottom(true)
|
||||
}, [cancelInitialSettleFrame, enterAnchoredFollowing, transitionScrollPhase])
|
||||
|
||||
const runInitialSettleFrame = useCallback(
|
||||
(taskTs: number) => {
|
||||
if (!isMountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!isSettlingRef.current || settleTaskTsRef.current !== taskTs) {
|
||||
return
|
||||
}
|
||||
|
||||
settleFrameCountRef.current += 1
|
||||
if (settleFrameCountRef.current > INITIAL_LOAD_SETTLE_MAX_FRAMES) {
|
||||
completeInitialSettle()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSettleWindowOpen(taskTs)) {
|
||||
completeInitialSettle()
|
||||
return
|
||||
}
|
||||
|
||||
const mutationVersion = settleMutationVersionRef.current
|
||||
const isTailStable = mutationVersion === settleObservedMutationVersionRef.current
|
||||
settleObservedMutationVersionRef.current = mutationVersion
|
||||
|
||||
if (isAtBottomRef.current && settleBottomConfirmedRef.current && isTailStable) {
|
||||
settleStableFramesRef.current += 1
|
||||
} else {
|
||||
settleStableFramesRef.current = 0
|
||||
}
|
||||
|
||||
virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
|
||||
|
||||
if (settleStableFramesRef.current >= INITIAL_LOAD_SETTLE_STABLE_FRAME_TARGET) {
|
||||
completeInitialSettle()
|
||||
return
|
||||
}
|
||||
|
||||
settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs))
|
||||
},
|
||||
[completeInitialSettle, isSettleWindowOpen],
|
||||
)
|
||||
|
||||
const startInitialSettle = useCallback(
|
||||
(taskTs: number) => {
|
||||
if (scrollPhaseRef.current !== "HYDRATING_PINNED_TO_BOTTOM") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSettleWindowOpen(taskTs)) {
|
||||
return
|
||||
}
|
||||
if (isSettlingRef.current && settleTaskTsRef.current === taskTs) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelInitialSettleFrame()
|
||||
settleTaskTsRef.current = taskTs
|
||||
isSettlingRef.current = true
|
||||
settleStableFramesRef.current = 0
|
||||
settleFrameCountRef.current = 0
|
||||
settleObservedMutationVersionRef.current = settleMutationVersionRef.current
|
||||
settleAnimationFrameRef.current = requestAnimationFrame(() => runInitialSettleFrame(taskTs))
|
||||
},
|
||||
[cancelInitialSettleFrame, isSettleWindowOpen, runInitialSettleFrame],
|
||||
)
|
||||
|
||||
const {
|
||||
isOpen: isUpsellOpen,
|
||||
openUpsell,
|
||||
|
|
@ -223,8 +428,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
cancelReanchorFrame()
|
||||
}
|
||||
}, [])
|
||||
}, [cancelReanchorFrame])
|
||||
|
||||
const isProfileDisabled = useMemo(
|
||||
() => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList),
|
||||
|
|
@ -492,6 +698,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
const taskSwitchMs = Date.now()
|
||||
settleStableFramesRef.current = 0
|
||||
settleFrameCountRef.current = 0
|
||||
settleBottomConfirmedRef.current = false
|
||||
settleMutationVersionRef.current = 0
|
||||
settleObservedMutationVersionRef.current = 0
|
||||
isAtBottomRef.current = false
|
||||
cancelInitialSettleFrame()
|
||||
cancelReanchorFrame()
|
||||
settleTaskTsRef.current = task?.ts ?? null
|
||||
settleDeadlineMsRef.current = null
|
||||
settleHardDeadlineMsRef.current = null
|
||||
|
||||
// Reset UI states only when task changes
|
||||
setExpandedRows({})
|
||||
everVisibleMessagesTsRef.current.clear() // Clear for new task
|
||||
|
|
@ -507,23 +726,35 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// Reset user response flag for new task
|
||||
userRespondedRef.current = false
|
||||
|
||||
// Ensure new task starts anchored to the bottom. Virtuoso's
|
||||
// initialTopMostItemIndex fires at mount but the message data may
|
||||
// arrive asynchronously, so we also engage sticky follow and
|
||||
// explicitly scroll after a frame to handle the race.
|
||||
let rafId: number | undefined
|
||||
// Ensure new task starts in deterministic hydration mode, pinned to
|
||||
// the bottom until the message tail has settled.
|
||||
if (task?.ts) {
|
||||
stickyFollowRef.current = true
|
||||
rafId = requestAnimationFrame(() => {
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
|
||||
})
|
||||
beginHydrationPinnedToBottom()
|
||||
isSettlingRef.current = false
|
||||
settleDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_TIMEOUT_MS
|
||||
settleHardDeadlineMsRef.current = taskSwitchMs + INITIAL_LOAD_SETTLE_HARD_CAP_MS
|
||||
startInitialSettle(task.ts)
|
||||
} else {
|
||||
transitionScrollPhase("USER_BROWSING_HISTORY")
|
||||
setShowScrollToBottom(false)
|
||||
}
|
||||
return () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
cancelInitialSettleFrame()
|
||||
cancelReanchorFrame()
|
||||
settleTaskTsRef.current = null
|
||||
settleDeadlineMsRef.current = null
|
||||
settleHardDeadlineMsRef.current = null
|
||||
isSettlingRef.current = false
|
||||
settleBottomConfirmedRef.current = false
|
||||
}
|
||||
}, [task?.ts])
|
||||
}, [
|
||||
beginHydrationPinnedToBottom,
|
||||
cancelInitialSettleFrame,
|
||||
cancelReanchorFrame,
|
||||
startInitialSettle,
|
||||
task?.ts,
|
||||
transitionScrollPhase,
|
||||
])
|
||||
|
||||
const taskTs = task?.ts
|
||||
|
||||
|
|
@ -566,11 +797,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
// Expanding a row indicates the user is browsing; disable sticky follow
|
||||
if (wasAnyRowExpandedByUser) {
|
||||
stickyFollowRef.current = false
|
||||
enterUserBrowsingHistory("row-expansion")
|
||||
}
|
||||
|
||||
prevExpandedRowsRef.current = expandedRows // Store current state for next comparison
|
||||
}, [expandedRows])
|
||||
}, [enterUserBrowsingHistory, expandedRows])
|
||||
|
||||
const isStreaming = useMemo(() => {
|
||||
// Checking clineAsk isn't enough since messages effect may be called
|
||||
|
|
@ -1313,13 +1544,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
return result
|
||||
}, [isCondensing, visibleMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const previousLength = groupedMessagesLengthRef.current
|
||||
groupedMessagesLengthRef.current = groupedMessages.length
|
||||
|
||||
if (previousLength === groupedMessages.length) {
|
||||
return
|
||||
}
|
||||
|
||||
settleMutationVersionRef.current += 1
|
||||
|
||||
const settleTaskTs = settleTaskTsRef.current
|
||||
if (settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) {
|
||||
startInitialSettle(settleTaskTs)
|
||||
}
|
||||
}, [groupedMessages.length, extendInitialSettleWindow, startInitialSettle])
|
||||
|
||||
// scrolling
|
||||
|
||||
const scrollToBottomSmooth = useMemo(
|
||||
() =>
|
||||
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
|
||||
immediate: true,
|
||||
}),
|
||||
debounce(
|
||||
() => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }),
|
||||
10,
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
|
|
@ -1330,8 +1581,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}, [scrollToBottomSmooth])
|
||||
|
||||
const scrollToBottomAuto = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: "LAST",
|
||||
align: "end",
|
||||
behavior: "auto", // Instant causes crash.
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -1358,26 +1610,109 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const handleRowHeightChange = useCallback(
|
||||
(isTaller: boolean) => {
|
||||
if (isAtBottomRef.current) {
|
||||
settleMutationVersionRef.current += 1
|
||||
|
||||
const settleTaskTs = settleTaskTsRef.current
|
||||
if (isTaller && settleTaskTs !== null && extendInitialSettleWindow(settleTaskTs)) {
|
||||
startInitialSettle(settleTaskTs)
|
||||
}
|
||||
|
||||
const shouldAutoFollowBottom = scrollPhaseRef.current !== "USER_BROWSING_HISTORY"
|
||||
const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming
|
||||
if ((isAtBottomRef.current || shouldForcePinForAnchoredStreaming) && shouldAutoFollowBottom) {
|
||||
if (isTaller) {
|
||||
scrollToBottomSmooth()
|
||||
} else {
|
||||
setTimeout(() => scrollToBottomAuto(), 0)
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
}
|
||||
},
|
||||
[scrollToBottomSmooth, scrollToBottomAuto],
|
||||
[extendInitialSettleWindow, isStreaming, scrollToBottomSmooth, scrollToBottomAuto, startInitialSettle],
|
||||
)
|
||||
|
||||
// Disable sticky follow when user scrolls up inside the chat container
|
||||
const handleWheel = useCallback((event: Event) => {
|
||||
const wheelEvent = event as WheelEvent
|
||||
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
|
||||
stickyFollowRef.current = false
|
||||
}
|
||||
}, [])
|
||||
const handleWheel = useCallback(
|
||||
(event: Event) => {
|
||||
const wheelEvent = event as WheelEvent
|
||||
if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
|
||||
enterUserBrowsingHistory("wheel-up")
|
||||
}
|
||||
},
|
||||
[enterUserBrowsingHistory],
|
||||
)
|
||||
useEvent("wheel", handleWheel, window, { passive: true })
|
||||
|
||||
const handlePointerDown = useCallback((event: Event) => {
|
||||
const pointerEvent = event as PointerEvent
|
||||
const pointerTarget = pointerEvent.target
|
||||
if (!(pointerTarget instanceof HTMLElement)) {
|
||||
pointerScrollActiveRef.current = false
|
||||
pointerScrollLastTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!scrollContainerRef.current?.contains(pointerTarget)) {
|
||||
pointerScrollActiveRef.current = false
|
||||
pointerScrollLastTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const scroller =
|
||||
(pointerTarget.closest(".scrollable") as HTMLElement | null) ??
|
||||
(pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null)
|
||||
|
||||
pointerScrollActiveRef.current = true
|
||||
pointerScrollLastTopRef.current = scroller?.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const handlePointerEnd = useCallback(() => {
|
||||
pointerScrollActiveRef.current = false
|
||||
pointerScrollLastTopRef.current = null
|
||||
}, [])
|
||||
|
||||
const handlePointerActiveScroll = useCallback(
|
||||
(event: Event) => {
|
||||
if (!pointerScrollActiveRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTarget = event.target
|
||||
if (!(scrollTarget instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!scrollContainerRef.current?.contains(scrollTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousTop = pointerScrollLastTopRef.current
|
||||
const currentTop = scrollTarget.scrollTop
|
||||
pointerScrollLastTopRef.current = currentTop
|
||||
|
||||
if (previousTop !== null && currentTop < previousTop) {
|
||||
enterUserBrowsingHistory("pointer-scroll-up")
|
||||
}
|
||||
},
|
||||
[enterUserBrowsingHistory],
|
||||
)
|
||||
|
||||
useEvent("pointerdown", handlePointerDown, window, { passive: true })
|
||||
useEvent("pointerup", handlePointerEnd, window, { passive: true })
|
||||
useEvent("pointercancel", handlePointerEnd, window, { passive: true })
|
||||
useEvent("scroll", handlePointerActiveScroll, window, { passive: true, capture: true })
|
||||
|
||||
const handleScrollToBottomClick = useCallback(() => {
|
||||
enterAnchoredFollowing()
|
||||
scrollToBottomAuto()
|
||||
cancelReanchorFrame()
|
||||
reanchorAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||
reanchorAnimationFrameRef.current = null
|
||||
if (scrollPhaseRef.current === "ANCHORED_FOLLOWING") {
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
})
|
||||
}, [cancelReanchorFrame, enterAnchoredFollowing, scrollToBottomAuto])
|
||||
|
||||
// Effect to clear checkpoint warning when messages appear or task changes
|
||||
useEffect(() => {
|
||||
if (isHidden || !task) {
|
||||
|
|
@ -1537,9 +1872,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// Just Period = Next mode
|
||||
switchToNextMode()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!task || isHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "PageUp" && event.key !== "Home" && event.key !== "ArrowUp") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableKeyboardTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement
|
||||
const focusInsideChat =
|
||||
activeElement instanceof HTMLElement && !!scrollContainerRef.current?.contains(activeElement)
|
||||
const eventTargetInsideChat =
|
||||
event.target instanceof Node && !!scrollContainerRef.current?.contains(event.target)
|
||||
|
||||
if (focusInsideChat || eventTargetInsideChat || activeElement === document.body) {
|
||||
enterUserBrowsingHistory("keyboard-nav-up")
|
||||
}
|
||||
},
|
||||
[switchToNextMode, switchToPreviousMode],
|
||||
[enterUserBrowsingHistory, isHidden, switchToNextMode, switchToPreviousMode, task],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1687,14 +2049,43 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
|
||||
data={groupedMessages}
|
||||
itemContent={itemContent}
|
||||
followOutput={(isAtBottom: boolean) => isAtBottom || stickyFollowRef.current}
|
||||
followOutput={() => (scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto")}
|
||||
atBottomStateChange={(isAtBottom: boolean) => {
|
||||
isAtBottomRef.current = isAtBottom
|
||||
setShowScrollToBottom(!isAtBottom)
|
||||
// Clear sticky follow when user scrolls away from bottom
|
||||
if (!isAtBottom) {
|
||||
stickyFollowRef.current = false
|
||||
|
||||
const currentPhase = scrollPhaseRef.current
|
||||
if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && isAtBottom) {
|
||||
settleBottomConfirmedRef.current = true
|
||||
}
|
||||
|
||||
if (currentPhase === "HYDRATING_PINNED_TO_BOTTOM" && !isAtBottom) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
currentPhase === "ANCHORED_FOLLOWING" &&
|
||||
!isAtBottom &&
|
||||
pointerScrollActiveRef.current
|
||||
) {
|
||||
enterUserBrowsingHistory("pointer-scroll-up")
|
||||
return
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
setShowScrollToBottom(false)
|
||||
if (currentPhase === "USER_BROWSING_HISTORY") {
|
||||
enterAnchoredFollowing()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) {
|
||||
scrollToBottomAuto()
|
||||
setShowScrollToBottom(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShowScrollToBottom(currentPhase === "USER_BROWSING_HISTORY")
|
||||
}}
|
||||
atBottomThreshold={10}
|
||||
initialTopMostItemIndex={groupedMessages.length - 1}
|
||||
|
|
@ -1710,14 +2101,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
<Button
|
||||
variant="secondary"
|
||||
className="flex-[2]"
|
||||
onClick={() => {
|
||||
// Engage sticky follow until user scrolls up
|
||||
stickyFollowRef.current = true
|
||||
// Pin immediately to avoid lag during fast streaming
|
||||
scrollToBottomAuto()
|
||||
// Hide button immediately to prevent flash
|
||||
setShowScrollToBottom(false)
|
||||
}}>
|
||||
onClick={handleScrollToBottomClick}>
|
||||
<span className="codicon codicon-chevron-down"></span>
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
|
|
@ -1823,7 +2207,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
onSelectImages={selectImages}
|
||||
shouldDisableImages={shouldDisableImages}
|
||||
onHeightChange={() => {
|
||||
if (isAtBottomRef.current) {
|
||||
if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") {
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,386 @@
|
|||
import React, { useEffect, useImperativeHandle, useRef } from "react"
|
||||
import { act, fireEvent, render, waitFor } from "@/utils/test-utils"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
|
||||
import ChatView, { type ChatViewProps } from "../ChatView"
|
||||
|
||||
type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false
|
||||
|
||||
interface ExtensionStateMessage {
|
||||
type: "state"
|
||||
state: {
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: unknown[]
|
||||
shouldShowAnnouncement: boolean
|
||||
allowedCommands: string[]
|
||||
alwaysAllowExecute: boolean
|
||||
cloudIsAuthenticated: boolean
|
||||
telemetrySetting: "enabled" | "disabled" | "unset"
|
||||
}
|
||||
}
|
||||
|
||||
interface MockVirtuosoHandle {
|
||||
scrollToIndex: (options: {
|
||||
index: number | "LAST"
|
||||
align?: "end" | "start" | "center"
|
||||
behavior?: "auto" | "smooth"
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface MockVirtuosoProps {
|
||||
data: ClineMessage[]
|
||||
itemContent: (index: number, item: ClineMessage) => React.ReactNode
|
||||
atBottomStateChange?: (isAtBottom: boolean) => void
|
||||
followOutput?: FollowOutput
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface VirtuosoHarnessState {
|
||||
scrollCalls: number
|
||||
atBottomAfterCalls: number
|
||||
signalDelayMs: number
|
||||
emitFalseOnDataChange: boolean
|
||||
followOutput: FollowOutput | undefined
|
||||
}
|
||||
|
||||
const harness = vi.hoisted<VirtuosoHarnessState>(() => ({
|
||||
scrollCalls: 0,
|
||||
atBottomAfterCalls: Number.POSITIVE_INFINITY,
|
||||
signalDelayMs: 20,
|
||||
emitFalseOnDataChange: true,
|
||||
followOutput: undefined,
|
||||
}))
|
||||
|
||||
function nullDefaultModule() {
|
||||
return { default: () => null }
|
||||
}
|
||||
|
||||
vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } }))
|
||||
vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [vi.fn()]) }))
|
||||
vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ CloudUpsellDialog: () => null }))
|
||||
vi.mock("@src/hooks/useCloudUpsell", () => ({
|
||||
useCloudUpsell: () => ({
|
||||
isOpen: false,
|
||||
openUpsell: vi.fn(),
|
||||
closeUpsell: vi.fn(),
|
||||
handleConnect: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../common/TelemetryBanner", nullDefaultModule)
|
||||
vi.mock("../common/VersionIndicator", nullDefaultModule)
|
||||
vi.mock("../history/HistoryPreview", nullDefaultModule)
|
||||
vi.mock("@src/components/welcome/RooHero", nullDefaultModule)
|
||||
vi.mock("@src/components/welcome/RooTips", nullDefaultModule)
|
||||
vi.mock("../Announcement", nullDefaultModule)
|
||||
vi.mock("./TaskHeader", () => ({ default: () => <div data-testid="task-header" /> }))
|
||||
vi.mock("./ProfileViolationWarning", nullDefaultModule)
|
||||
vi.mock("../common/DismissibleUpsell", nullDefaultModule)
|
||||
|
||||
vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null }))
|
||||
vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null }))
|
||||
vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null }))
|
||||
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/ui")>()
|
||||
return {
|
||||
...actual,
|
||||
StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../ChatTextArea", () => {
|
||||
const MockTextArea = React.forwardRef(function MockTextArea(
|
||||
props: {
|
||||
inputValue?: string
|
||||
setInputValue?: (value: string) => void
|
||||
onSend: () => void
|
||||
sendingDisabled?: boolean
|
||||
},
|
||||
ref: React.ForwardedRef<{ focus: () => void }>,
|
||||
) {
|
||||
useImperativeHandle(ref, () => ({ focus: () => {} }))
|
||||
|
||||
return (
|
||||
<input
|
||||
value={props.inputValue ?? ""}
|
||||
onChange={(event) => props.setInputValue?.(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !props.sendingDisabled) {
|
||||
props.onSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return { default: MockTextArea, ChatTextArea: MockTextArea }
|
||||
})
|
||||
|
||||
vi.mock("../ChatRow", () => ({
|
||||
default: ({ message }: { message: ClineMessage }) => <div data-testid="chat-row">{message.ts}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("react-virtuoso", () => {
|
||||
const MockVirtuoso = React.forwardRef<MockVirtuosoHandle, MockVirtuosoProps>(function MockVirtuoso(
|
||||
{ data, itemContent, atBottomStateChange, followOutput, className },
|
||||
ref,
|
||||
) {
|
||||
const atBottomRef = useRef(atBottomStateChange)
|
||||
const timeoutIdsRef = useRef<number[]>([])
|
||||
|
||||
harness.followOutput = followOutput
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: () => {
|
||||
harness.scrollCalls += 1
|
||||
const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
atBottomRef.current?.(reachedBottom)
|
||||
}, harness.signalDelayMs)
|
||||
timeoutIdsRef.current.push(timeoutId)
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
atBottomRef.current = atBottomStateChange
|
||||
}, [atBottomStateChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (harness.emitFalseOnDataChange) {
|
||||
atBottomStateChange?.(false)
|
||||
}
|
||||
}, [data.length, atBottomStateChange])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id))
|
||||
timeoutIdsRef.current = []
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-testid="virtuoso-item-list" className={className} data-count={data.length}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return { Virtuoso: MockVirtuoso }
|
||||
})
|
||||
|
||||
const props: ChatViewProps = {
|
||||
isHidden: false,
|
||||
showAnnouncement: false,
|
||||
hideAnnouncement: () => {},
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
const buildMessages = (baseTs: number): ClineMessage[] => [
|
||||
{ type: "say", say: "text", ts: baseTs, text: "task" },
|
||||
{ type: "say", say: "text", ts: baseTs + 1, text: "row-1" },
|
||||
{ type: "say", say: "text", ts: baseTs + 2, text: "row-2" },
|
||||
]
|
||||
|
||||
const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => {
|
||||
const followOutput = harness.followOutput
|
||||
if (typeof followOutput === "function") {
|
||||
return followOutput(isAtBottom)
|
||||
}
|
||||
return followOutput === "auto" ? "auto" : false
|
||||
}
|
||||
|
||||
const postState = (clineMessages: ClineMessage[]) => {
|
||||
const message: ExtensionStateMessage = {
|
||||
type: "state",
|
||||
state: {
|
||||
version: "1.0.0",
|
||||
clineMessages,
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
allowedCommands: [],
|
||||
alwaysAllowExecute: false,
|
||||
cloudIsAuthenticated: false,
|
||||
telemetrySetting: "enabled",
|
||||
},
|
||||
}
|
||||
|
||||
window.postMessage(message, "*")
|
||||
}
|
||||
|
||||
const renderView = () =>
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ChatView {...props} />
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
const hydrate = async (atBottomAfterCalls: number) => {
|
||||
harness.atBottomAfterCalls = atBottomAfterCalls
|
||||
renderView()
|
||||
await act(async () => {
|
||||
postState(buildMessages(Date.now() - 3_000))
|
||||
})
|
||||
}
|
||||
|
||||
const waitForCalls = async (min: number, timeout = 1_500) => {
|
||||
await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout })
|
||||
}
|
||||
|
||||
const expectCallsStable = async (ms = 120) => {
|
||||
await sleep(ms)
|
||||
const snapshot = harness.scrollCalls
|
||||
await sleep(ms)
|
||||
expect(harness.scrollCalls).toBe(snapshot)
|
||||
}
|
||||
|
||||
const getScrollable = (): HTMLElement => {
|
||||
const scrollable = document.querySelector(".scrollable")
|
||||
if (!(scrollable instanceof HTMLElement)) {
|
||||
throw new Error("Expected ChatView scrollable container")
|
||||
}
|
||||
return scrollable
|
||||
}
|
||||
|
||||
const getScrollToBottomButton = (): HTMLButtonElement => {
|
||||
const icon = document.querySelector(".codicon-chevron-down")
|
||||
if (!(icon instanceof HTMLElement)) {
|
||||
throw new Error("Expected scroll-to-bottom icon")
|
||||
}
|
||||
|
||||
const button = icon.closest("button")
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error("Expected scroll-to-bottom button")
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
describe("ChatView scroll behavior regression coverage", () => {
|
||||
beforeEach(() => {
|
||||
harness.scrollCalls = 0
|
||||
harness.atBottomAfterCalls = Number.POSITIVE_INFINITY
|
||||
harness.signalDelayMs = 20
|
||||
harness.emitFalseOnDataChange = true
|
||||
harness.followOutput = undefined
|
||||
})
|
||||
|
||||
it("rehydration converges to bottom", async () => {
|
||||
await hydrate(6)
|
||||
await waitForCalls(6, 2_000)
|
||||
await expectCallsStable()
|
||||
expect(document.querySelector(".codicon-chevron-down")).toBeNull()
|
||||
})
|
||||
|
||||
it("transient settle-time not-at-bottom signals do not disable sticky follow", async () => {
|
||||
await hydrate(8)
|
||||
await waitForCalls(2, 1_200)
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
expect(document.querySelector(".codicon-chevron-down")).toBeNull()
|
||||
|
||||
await waitForCalls(8, 2_000)
|
||||
await expectCallsStable()
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
})
|
||||
|
||||
it("user escape hatch during settle stops forced follow", async () => {
|
||||
await hydrate(Number.POSITIVE_INFINITY)
|
||||
await waitForCalls(3, 1_200)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "PageUp" })
|
||||
})
|
||||
|
||||
expect(resolveFollowOutput(false)).toBe(false)
|
||||
const callsAfterEscape = harness.scrollCalls
|
||||
await sleep(260)
|
||||
expect(harness.scrollCalls).toBe(callsAfterEscape)
|
||||
|
||||
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
|
||||
timeout: 1_200,
|
||||
})
|
||||
})
|
||||
|
||||
it("non-wheel upward intent disengages sticky follow", async () => {
|
||||
await hydrate(4)
|
||||
await waitForCalls(4)
|
||||
await expectCallsStable()
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
|
||||
const scrollable = getScrollable()
|
||||
scrollable.scrollTop = 240
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(scrollable)
|
||||
scrollable.scrollTop = 120
|
||||
fireEvent.scroll(scrollable)
|
||||
fireEvent.pointerUp(window)
|
||||
})
|
||||
|
||||
expect(resolveFollowOutput(false)).toBe(false)
|
||||
})
|
||||
|
||||
it("wheel-up intent disengages sticky follow", async () => {
|
||||
await hydrate(4)
|
||||
await waitForCalls(4)
|
||||
await expectCallsStable()
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
|
||||
const scrollable = getScrollable()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(scrollable, { deltaY: -120 })
|
||||
})
|
||||
|
||||
expect(resolveFollowOutput(false)).toBe(false)
|
||||
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
|
||||
timeout: 1_200,
|
||||
})
|
||||
})
|
||||
|
||||
it("scroll-to-bottom CTA re-anchors with one interaction", async () => {
|
||||
await hydrate(4)
|
||||
await waitForCalls(4)
|
||||
await expectCallsStable()
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "PageUp" })
|
||||
})
|
||||
|
||||
expect(resolveFollowOutput(false)).toBe(false)
|
||||
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), {
|
||||
timeout: 1_200,
|
||||
})
|
||||
|
||||
const callsBeforeClick = harness.scrollCalls
|
||||
harness.atBottomAfterCalls = callsBeforeClick + 2
|
||||
|
||||
await act(async () => {
|
||||
getScrollToBottomButton().click()
|
||||
})
|
||||
|
||||
expect(resolveFollowOutput(false)).toBe("auto")
|
||||
await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(callsBeforeClick + 2), {
|
||||
timeout: 1_200,
|
||||
})
|
||||
await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue