Skip to content

Commit eb7eff6

Browse files
committed
More robust auto scrolling
1 parent fb58497 commit eb7eff6

File tree

3 files changed

+91
-117
lines changed

3 files changed

+91
-117
lines changed

src/client/components/ChatV2/ChatV2.tsx

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import RestartAltIcon from '@mui/icons-material/RestartAlt'
22
import HelpIcon from '@mui/icons-material/Help'
3-
import { Alert, Box, Drawer, FormControlLabel, Paper, Switch, Typography, useMediaQuery, useTheme } from '@mui/material'
4-
import { useContext, useEffect, useRef, useState } from 'react'
3+
import { Alert, Box, Drawer, Fab, FormControlLabel, Paper, Switch, Typography, useMediaQuery, useTheme } from '@mui/material'
4+
import React, { useContext, useEffect, useRef, useState } from 'react'
55
import { useTranslation } from 'react-i18next'
66
import { useParams, useSearchParams } from 'react-router-dom'
77
import { DEFAULT_ASSISTANT_INSTRUCTIONS, DEFAULT_MODEL, DEFAULT_MODEL_TEMPERATURE, FREE_MODEL, inProduction, validModels } from '../../../config'
@@ -31,7 +31,7 @@ import { useIsEmbedded } from '../../contexts/EmbeddedContext'
3131
import { enqueueSnackbar } from 'notistack'
3232
import { useAnalyticsDispatch } from '../../stores/analytics'
3333
import EmailButton from './EmailButton'
34-
import { MenuBookTwoTone, Tune } from '@mui/icons-material'
34+
import { ArrowDownward, MenuBookTwoTone, Tune } from '@mui/icons-material'
3535
import { useChatScroll } from '../../hooks/useChatScroll'
3636
import { TestUseInfoV2 } from './TestUseInfo'
3737
import Footer from '../Footer'
@@ -115,16 +115,14 @@ export const ChatV2 = () => {
115115
}, [messages, courseId, ragIndexId, activeModel, dispatchAnalytics])
116116

117117
// Refs
118-
const appContainerRef = useContext(AppContext)
119118
const chatContainerRef = useRef<HTMLDivElement | null>(null)
120119
const conversationRef = useRef<HTMLElement | null>(null)
121120
const inputFieldRef = useRef<HTMLElement | null>(null)
122121
const fileInputRef = useRef<HTMLInputElement | null>(null)
123-
const endOfConversationRef = useRef<HTMLDivElement | null>(null)
124122
const scrollRef = useRef<HTMLDivElement | null>(null)
125123
const [setRetryTimeout, clearRetryTimeout] = useRetryTimeout()
126124

127-
const chatScroll = useChatScroll(scrollRef, endOfConversationRef) //removing this will break chat autoscroll behavior
125+
const chatScroll = useChatScroll()
128126

129127
const { t, i18n } = useTranslation()
130128

@@ -160,7 +158,6 @@ export const ChatV2 = () => {
160158

161159
const handleSubmit = async (message: string, ignoreTokenUsageWarning: boolean) => {
162160
if (!userStatus) return
163-
// chatScroll.autoScroll()
164161
const { usage, limit } = userStatus
165162
const tokenUsageExceeded = usage >= limit
166163

@@ -196,6 +193,7 @@ export const ChatV2 = () => {
196193
}, 5000)
197194

198195
setIsStreaming(true)
196+
chatScroll.beginAutoscroll()
199197

200198
try {
201199
const { tokenUsageAnalysis, stream } = await getCompletionStream({
@@ -354,8 +352,8 @@ export const ChatV2 = () => {
354352
}}
355353
>
356354
{/* Chat side panel column -------------------------------------------------------------------------------------------*/}
357-
{!isEmbeddedMode && (
358-
<>
355+
{!isEmbeddedMode &&
356+
(isMobile ? (
359357
<Drawer
360358
open={chatLeftSidePanelOpen}
361359
onClose={() => {
@@ -377,22 +375,25 @@ export const ChatV2 = () => {
377375
messages={messages}
378376
/>
379377
</Drawer>
380-
<LeftMenu
381-
sx={{ display: { xs: 'none', lg: 'flex' }, position: 'sticky', top: 0 }}
382-
course={course}
383-
handleReset={handleReset}
384-
user={user}
385-
t={t}
386-
setSettingsModalOpen={setSettingsModalOpen}
387-
setDisclaimerStatus={setDisclaimerStatus}
388-
showRagSelector={showRagSelector}
389-
ragIndex={ragIndex}
390-
setRagIndexId={setRagIndexId}
391-
ragIndices={ragIndices}
392-
messages={messages}
393-
/>
394-
</>
395-
)}
378+
) : (
379+
<>
380+
<LeftMenu
381+
sx={{ display: { xs: 'none', lg: 'flex' }, position: 'fixed', top: 0 }}
382+
course={course}
383+
handleReset={handleReset}
384+
user={user}
385+
t={t}
386+
setSettingsModalOpen={setSettingsModalOpen}
387+
setDisclaimerStatus={setDisclaimerStatus}
388+
showRagSelector={showRagSelector}
389+
ragIndex={ragIndex}
390+
setRagIndexId={setRagIndexId}
391+
ragIndices={ragIndices}
392+
messages={messages}
393+
/>
394+
<Box width={400} /> {/* Holds space for left menu */}
395+
</>
396+
))}
396397

397398
{/* Chat view column ------------------------------------------------------------------------------------------------ */}
398399
<Box
@@ -463,7 +464,6 @@ export const ChatV2 = () => {
463464
isFileSearching={isFileSearching}
464465
setActiveFileSearchResult={setActiveFileSearchResult}
465466
setShowAnnotations={setShowAnnotations}
466-
endOfConversationRef={endOfConversationRef}
467467
/>
468468
</Box>
469469

@@ -510,16 +510,19 @@ export const ChatV2 = () => {
510510
handleCancel={handleCancel}
511511
handleContinue={(newMessage) => handleSubmit(newMessage, true)}
512512
handleSubmit={(newMessage) => {
513-
console.log('handle submit called!')
514-
chatScroll.shouldScroll.current = true
515-
chatScroll.autoScroll()
516513
handleSubmit(newMessage, false)
517514
}}
518515
handleReset={handleReset}
519516
/>
520517
</Box>
521518
</Box>
522519

520+
{!chatScroll.isAutoScrolling && (
521+
<Fab sx={{ position: 'fixed', right: 32, bottom: '12rem' }} onClick={() => chatScroll.beginAutoscroll()}>
522+
<ArrowDownward />
523+
</Fab>
524+
)}
525+
523526
{/* Annotations columns ----------------------------------------------------------------------------------------------------- */}
524527

525528
{isMobile && (
@@ -612,7 +615,6 @@ const LeftMenu = ({
612615
height: '100vh',
613616
borderRight: '1px solid rgba(0, 0, 0, 0.12)',
614617
paddingTop: '4rem',
615-
616618
display: 'flex',
617619
flexDirection: 'column',
618620
},

src/client/components/ChatV2/Conversation.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,6 @@ export const Conversation = ({
318318
isStreaming,
319319
setActiveFileSearchResult,
320320
setShowAnnotations,
321-
endOfConversationRef,
322321
}: {
323322
courseName?: string
324323
courseDate?: ActivityPeriod
@@ -330,7 +329,6 @@ export const Conversation = ({
330329
isStreaming: boolean
331330
setActiveFileSearchResult: (data: FileSearchCompletedData) => void
332331
setShowAnnotations: (show: boolean) => void
333-
endOfConversationRef: MutableRefObject<HTMLDivElement | null>
334332
}) => {
335333
const [reminderSeen, setReminderSeen] = useLocalStorageState<boolean>('reminderSeen', false)
336334

@@ -376,7 +374,6 @@ export const Conversation = ({
376374
) : (
377375
<LoadingMessage expandedNodeHeight={expandedNodeHeight} isFileSearching={isFileSearching} />
378376
))}
379-
<div ref={endOfConversationRef}></div>
380377
</Box>
381378
{!reminderSeen && !isStreaming && messages.length > 15 && (
382379
<Paper variant="outlined" sx={{ display: 'flex', flexDirection: 'row', gap: 2, fontStyle: 'italic', alignItems: 'center', padding: 2 }}>
@@ -386,6 +383,9 @@ export const Conversation = ({
386383
</BlueButton>
387384
</Paper>
388385
)}
386+
387+
{/* Buffer element */}
388+
<Box height="2rem" />
389389
</>
390390
)
391391
}

src/client/hooks/useChatScroll.ts

Lines changed: 56 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,79 @@
1-
import { MutableRefObject, useEffect, useRef, useState } from 'react'
1+
import { useEffect, useState } from 'react'
22

3-
export const useChatScroll = (appContainerRef, endOfConversationRef) => {
4-
const oldScrollValue = useRef<number>(0)
5-
const shouldScroll = useRef<boolean>(true)
6-
const startTime = useRef<number | null>(0)
7-
const elapsed = useRef<number>(0)
8-
const progress = useRef<number>(0)
9-
const ticks = useRef<number>(0)
3+
let isUserDisabled = false
104

11-
const scrollAnimationFrame: MutableRefObject<number | null> = useRef(null)
12-
//is called by 'scroll' event listener
13-
function handleUserScroll() {
14-
const bottomOffset = 10 // pixels
15-
if (!appContainerRef?.current) {
16-
return
17-
}
18-
const scrollValue: number | undefined = appContainerRef.current?.scrollTop
19-
// console.log("it works")
20-
const maxScrollValue: number = appContainerRef.current?.scrollHeight - appContainerRef.current.clientHeight
21-
if (!scrollValue) {
22-
return
23-
}
24-
if (scrollValue + 10 < oldScrollValue.current) {
25-
cancelScroll()
26-
}
5+
export const useChatScroll = () => {
6+
// Todo: on long conversations this state update is not optimal. Ideally move it down the component tree.
7+
const [isAutoScrolling, setIsAutoScrolling] = useState(false)
278

28-
if (scrollValue >= maxScrollValue - bottomOffset) {
29-
shouldScroll.current = true
30-
}
9+
const autoScroll = () => {
10+
if (isUserDisabled) return
3111

32-
oldScrollValue.current = scrollValue != undefined ? scrollValue : 0
33-
}
34-
//is called by the setInterval call
35-
async function autoScroll() {
36-
if (!endOfConversationRef?.current || shouldScroll.current === false) {
37-
cancelScroll()
38-
return
39-
}
40-
//lets not start another animation if there is one already
41-
if (scrollAnimationFrame.current) {
42-
return
43-
}
44-
smoothScrollTo(1000)
12+
window.scrollTo({
13+
top: document.body.scrollHeight,
14+
})
4515
}
4616

47-
const cancelScroll = () => {
48-
startTime.current = null
49-
elapsed.current = 1
50-
progress.current = 0
51-
ticks.current = 0
52-
shouldScroll.current = false
53-
if (scrollAnimationFrame?.current) {
54-
cancelAnimationFrame(scrollAnimationFrame.current)
55-
scrollAnimationFrame.current = null
56-
}
17+
const beginAutoscroll = () => {
18+
isUserDisabled = false
19+
setIsAutoScrolling(true)
20+
window.scrollTo({
21+
top: document.body.scrollHeight,
22+
})
5723
}
58-
function step(currentTime) {
59-
if (shouldScroll.current === false) {
60-
cancelScroll()
61-
return
24+
25+
useEffect(() => {
26+
const handleAttachAutoScroll = () => {
27+
const element = document.documentElement
28+
const isAtBottom = Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 3
29+
if (isAtBottom) {
30+
setIsAutoScrolling(true)
31+
isUserDisabled = false
32+
}
6233
}
6334

64-
const duration = 10000
65-
ticks.current++
35+
const detachAutoScroll = () => {
36+
isUserDisabled = true
37+
setIsAutoScrolling(false)
38+
}
6639

67-
if (!startTime.current) {
68-
startTime.current = performance.now()
40+
const handleDetachScrollOnWheel = (ev: WheelEvent) => {
41+
// Check that wheel is moved so content is scrolled back up
42+
if (ev.deltaY < 0) {
43+
detachAutoScroll()
44+
}
6945
}
70-
elapsed.current = performance.now() - startTime.current
71-
progress.current = Math.min(elapsed.current / duration, 1)
72-
const startY = appContainerRef.current.scrollTop
73-
const targetY = endOfConversationRef.current.offsetTop
74-
const distance = targetY - startY
75-
const viewPortHeight = appContainerRef.current.getBoundingClientRect().height
76-
appContainerRef.current.scrollTo(0, startY + distance * progress.current)
7746

78-
if (progress.current < 1 && distance > viewPortHeight) {
79-
scrollAnimationFrame.current = requestAnimationFrame(step)
80-
} else {
81-
cancelScroll()
47+
const handleDetachScrollOnTouchMove = (ev: TouchEvent) => {
48+
// Check that touch is moved
49+
if (ev.changedTouches.length > 0) {
50+
detachAutoScroll()
51+
}
8252
}
83-
}
8453

85-
const smoothScrollTo = (duration: number) => {
86-
if (!shouldScroll.current) {
87-
return
54+
const handleDetachScrollOnKeydown = (ev: KeyboardEvent) => {
55+
// Check that upwards scrolling key is pressed
56+
if (ev.key === 'ArrowUp' || ev.key === 'PageUp') {
57+
detachAutoScroll()
58+
}
8859
}
89-
//should scroll but there might be another scroll frame going so first it should be cancelled
90-
cancelScroll() //<--- cleans up everything
91-
shouldScroll.current = true // makes sure the animation can run
9260

93-
scrollAnimationFrame.current = requestAnimationFrame(step)
94-
}
61+
window.addEventListener('wheel', handleDetachScrollOnWheel)
62+
window.addEventListener('touchmove', handleDetachScrollOnTouchMove)
63+
window.addEventListener('keydown', handleDetachScrollOnKeydown)
64+
window.addEventListener('scrollend', handleAttachAutoScroll)
9565

96-
useEffect(() => {
97-
if (!appContainerRef?.current || !endOfConversationRef.current) return
98-
appContainerRef.current.addEventListener('scroll', handleUserScroll)
9966
return () => {
100-
appContainerRef?.current?.removeEventListener('scroll', handleUserScroll)
67+
window.removeEventListener('wheel', handleDetachScrollOnWheel)
68+
window.removeEventListener('touchmove', handleDetachScrollOnTouchMove)
69+
window.removeEventListener('keydown', handleDetachScrollOnKeydown)
70+
window.removeEventListener('scrollend', handleAttachAutoScroll)
10171
}
102-
}, [shouldScroll])
72+
}, [])
73+
10374
return {
104-
shouldScroll,
10575
autoScroll,
76+
isAutoScrolling,
77+
beginAutoscroll,
10678
}
10779
}

0 commit comments

Comments
 (0)