Skip to content

Commit fa2cab6

Browse files
fix(max): auto-scrolling experience (#32665)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent a6c61d9 commit fa2cab6

14 files changed

+185
-31
lines changed
Loading
Loading
Loading
Loading
Loading
Loading

frontend/src/scenes/max/Max.stories.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
formChunk,
1414
generationFailureChunk,
1515
humanMessage,
16+
longResponseChunk,
1617
} from './__mocks__/chatResponse.mocks'
1718
import conversationList from './__mocks__/conversationList.json'
1819
import { MaxInstance, MaxInstanceProps } from './Max'
@@ -367,3 +368,43 @@ ThreadWithOpenedSuggestions.parameters = {
367368
waitForLoadersToDisappear: false,
368369
},
369370
}
371+
372+
export const ThreadScrollsToBottomOnNewMessages: StoryFn = () => {
373+
useStorybookMocks({
374+
get: {
375+
'/api/environments/:team_id/conversations/': () => [200, conversationList],
376+
},
377+
post: {
378+
'/api/environments/:team_id/conversations/': (_, res, ctx) =>
379+
res(ctx.delay(100), ctx.text(longResponseChunk)),
380+
},
381+
})
382+
383+
const { conversation } = useValues(maxLogic)
384+
const { setConversationId } = useActions(maxLogic)
385+
const logic = maxThreadLogic({ conversationId: 'poem', conversation })
386+
const { threadRaw } = useValues(logic)
387+
const { askMax } = useActions(logic)
388+
389+
useEffect(() => {
390+
setConversationId('poem')
391+
}, [setConversationId])
392+
393+
const messagesSet = threadRaw.length > 0
394+
useEffect(() => {
395+
if (messagesSet) {
396+
askMax('This message must be on the top of the container')
397+
}
398+
}, [messagesSet, askMax])
399+
400+
return (
401+
<div className="h-[800px] overflow-y-auto SidePanel3000__content">
402+
<Template />
403+
</div>
404+
)
405+
}
406+
ThreadScrollsToBottomOnNewMessages.parameters = {
407+
testOptions: {
408+
waitForLoadersToDisappear: false,
409+
},
410+
}

frontend/src/scenes/max/Max.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { sidePanelLogic } from '~/layout/navigation-3000/sidepanel/sidePanelLogi
2727
import { SidePanelTab } from '~/types'
2828

2929
import { AnimatedBackButton } from './components/AnimatedBackButton'
30+
import { ThreadAutoScroller } from './components/ThreadAutoScroller'
3031
import { ConversationHistory } from './ConversationHistory'
3132
import { HistoryPreview } from './HistoryPreview'
3233
import { Intro } from './Intro'
@@ -212,10 +213,11 @@ export const MaxInstance = React.memo(function MaxInstance({ sidePanel }: MaxIns
212213
<HistoryPreview sidePanel={sidePanel} />
213214
</div>
214215
) : (
215-
<>
216+
/** Must be the last child and be a direct descendant of the scrollable element */
217+
<ThreadAutoScroller>
216218
<Thread />
217219
<QuestionInput isFloating />
218-
</>
220+
</ThreadAutoScroller>
219221
)}
220222
</BindLogic>
221223
</>

frontend/src/scenes/max/Thread.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ export function Thread(): JSX.Element | null {
8080
) : threadGrouped.length > 0 ? (
8181
threadGrouped.map((group, index) => (
8282
<MessageGroup
83-
key={index}
83+
// Reset the components when the thread changes
84+
key={`${conversationId}-${index}`}
8485
messages={group}
85-
index={index}
8686
isFinal={index === threadGrouped.length - 1}
8787
/>
8888
))
@@ -130,7 +130,6 @@ function MessageGroupContainer({
130130
interface MessageGroupProps {
131131
messages: ThreadMessage[]
132132
isFinal: boolean
133-
index: number
134133
}
135134

136135
function MessageGroup({ messages, isFinal: isFinalGroup }: MessageGroupProps): JSX.Element {
@@ -260,7 +259,7 @@ const MessageTemplate = React.forwardRef<HTMLDivElement, MessageTemplateProps>(f
260259
return (
261260
<div
262261
className={twMerge(
263-
'flex flex-col gap-px w-full break-words',
262+
'flex flex-col gap-px w-full break-words scroll-mt-12',
264263
type === 'human' ? 'items-end' : 'items-start',
265264
className
266265
)}
@@ -360,7 +359,7 @@ function AssistantMessageForm({ form }: AssistantMessageFormProps): JSX.Element
360359
)
361360
}
362361

363-
function VisualizationAnswer({
362+
const VisualizationAnswer = React.memo(function VisualizationAnswer({
364363
message,
365364
status,
366365
isEditingInsight,
@@ -458,7 +457,7 @@ function VisualizationAnswer({
458457
</MessageTemplate>
459458
</>
460459
)
461-
}
460+
})
462461

463462
function RetriableFailureActions(): JSX.Element {
464463
const { retryLastMessage } = useActions(maxThreadLogic)

frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,16 @@ const formMessage: AssistantMessage = {
9393
}
9494

9595
export const formChunk = generateChunk(['event: message', `data: ${JSON.stringify(formMessage)}`])
96+
97+
export const longMessage: AssistantMessage = {
98+
type: AssistantMessageType.Assistant,
99+
content: 'This\n\nis\n\na\n\nlong\n\nmessage\n\nthat\n\nshould\n\nbe\n\nsplit\n\ninto\n\nmultiple\n\nlines',
100+
id: 'assistant-2',
101+
}
102+
103+
export const longResponseChunk = generateChunk([
104+
'event: message',
105+
`data: ${JSON.stringify(humanMessage)}`,
106+
'event: message',
107+
`data: ${JSON.stringify(longMessage)}`,
108+
])
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useValues } from 'kea'
2+
import { useEffect, useRef } from 'react'
3+
4+
import { getScrollableContainer } from '../maxLogic'
5+
import { maxThreadLogic } from '../maxThreadLogic'
6+
7+
export function ThreadAutoScroller({ children }: { children: React.ReactNode }): JSX.Element {
8+
const { streamingActive, threadGrouped } = useValues(maxThreadLogic)
9+
10+
const scrollOrigin = useRef({ user: false, programmatic: false, resizing: false })
11+
const sentinelRef = useRef<HTMLDivElement | null>(null)
12+
13+
const scrollToBottom = useRef(() => {
14+
if (!sentinelRef.current) {
15+
return
16+
}
17+
18+
// Get the root scrollable element
19+
const scrollableContainer = getScrollableContainer(sentinelRef.current)
20+
21+
if (scrollableContainer) {
22+
// Lock the scroll listener, so we don't detect the programmatic scroll as a user scroll
23+
scrollOrigin.current.programmatic = true
24+
scrollableContainer.scrollTo({ top: scrollableContainer.scrollHeight })
25+
// Reset the scroll when we're done
26+
requestAnimationFrame(() => {
27+
scrollOrigin.current.programmatic = false
28+
})
29+
}
30+
}).current // Keep the stable reference
31+
32+
useEffect(() => {
33+
const scrollableContainer = getScrollableContainer(sentinelRef.current)
34+
if (!sentinelRef.current || !streamingActive || !scrollableContainer) {
35+
return
36+
}
37+
38+
// Detect if the user has scrolled the content during generation,
39+
// so we can stop auto-scrolling
40+
function scrollListener(event: Event): void {
41+
if (
42+
scrollOrigin.current.programmatic ||
43+
scrollOrigin.current.resizing ||
44+
!sentinelRef.current ||
45+
!event.target
46+
) {
47+
return
48+
}
49+
50+
const scrollableContainer = event.target as HTMLElement
51+
// Can be tracked through the IntersectionObserver, but the intersection observer event is fired after the scroll event,
52+
// so it adds an annoying delay.
53+
const isAtBottom =
54+
sentinelRef.current.getBoundingClientRect().top <= scrollableContainer.getBoundingClientRect().bottom
55+
56+
if (!isAtBottom) {
57+
scrollOrigin.current.user = true
58+
}
59+
}
60+
scrollableContainer.addEventListener('scroll', scrollListener)
61+
62+
// When the thread is resized during generation, we need to scroll to the bottom
63+
let resizeTimeout: NodeJS.Timeout | null = null
64+
// eslint-disable-next-line compat/compat
65+
const resizeObserver = new ResizeObserver(() => {
66+
if (scrollOrigin.current.user) {
67+
return
68+
}
69+
70+
if (resizeTimeout) {
71+
clearTimeout(resizeTimeout)
72+
resizeTimeout = null
73+
}
74+
75+
scrollOrigin.current.resizing = true
76+
scrollToBottom()
77+
// Block the scroll listener from firing
78+
resizeTimeout = setTimeout(() => {
79+
scrollOrigin.current.resizing = false
80+
resizeTimeout = null
81+
}, 50)
82+
})
83+
resizeObserver.observe(sentinelRef.current)
84+
85+
// Reset the user interaction if we've reached the bottom of the screen
86+
// eslint-disable-next-line compat/compat
87+
const intersectionObserver = new IntersectionObserver(([entries]) => {
88+
if (!scrollOrigin.current.programmatic && scrollOrigin.current.user && entries.isIntersecting) {
89+
scrollOrigin.current.user = false
90+
}
91+
})
92+
intersectionObserver.observe(sentinelRef.current)
93+
94+
return () => {
95+
resizeObserver.disconnect()
96+
intersectionObserver.disconnect()
97+
scrollableContainer.removeEventListener('scroll', scrollListener)
98+
scrollOrigin.current = { user: false, programmatic: false, resizing: false }
99+
if (resizeTimeout) {
100+
clearTimeout(resizeTimeout)
101+
}
102+
}
103+
}, [streamingActive, scrollToBottom])
104+
105+
useEffect(() => {
106+
if (!streamingActive || scrollOrigin.current.user) {
107+
return
108+
}
109+
scrollToBottom()
110+
}, [streamingActive, scrollToBottom, threadGrouped]) // Scroll when the thread updates
111+
112+
return (
113+
<>
114+
{children}
115+
<div id="max-sentinel" className="pointer-events-none h-0" ref={sentinelRef} />
116+
</>
117+
)
118+
}

frontend/src/scenes/max/maxLogic.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export const maxLogic = kea<maxLogicType>([
186186
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used for conversation restoration
187187
_?: {
188188
/** If true, the current thread will not be updated with the retrieved conversation. */
189-
doNotUpdateCurrentThread: boolean
189+
doNotUpdateCurrentThread?: boolean
190190
}
191191
) => {
192192
const response = await api.conversations.list()
@@ -249,7 +249,7 @@ export const maxLogic = kea<maxLogicType>([
249249
return (
250250
!conversationHistory.length &&
251251
conversationHistoryLoading &&
252-
conversationId &&
252+
!!conversationId &&
253253
!isTempId(conversationId) &&
254254
!conversation
255255
)
@@ -460,7 +460,7 @@ export const maxLogic = kea<maxLogicType>([
460460
permanentlyMount(), // Prevent state from being reset when Max is unmounted, especially key in the side panel
461461
])
462462

463-
function getScrollableContainer(element?: Element | null): HTMLElement | null {
463+
export function getScrollableContainer(element?: Element | null): HTMLElement | null {
464464
if (!element) {
465465
return null
466466
}

frontend/src/scenes/max/maxThreadLogic.tsx

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,7 @@ import { Conversation, ConversationDetail, ConversationStatus } from '~/types'
3535
import { maxGlobalLogic } from './maxGlobalLogic'
3636
import { maxLogic } from './maxLogic'
3737
import type { maxThreadLogicType } from './maxThreadLogicType'
38-
import {
39-
isAssistantMessage,
40-
isAssistantToolCallMessage,
41-
isHumanMessage,
42-
isReasoningMessage,
43-
isVisualizationMessage,
44-
} from './utils'
38+
import { isAssistantMessage, isAssistantToolCallMessage, isHumanMessage, isReasoningMessage } from './utils'
4539

4640
export type MessageStatus = 'loading' | 'completed' | 'error'
4741

@@ -106,7 +100,6 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
106100
'prependOrReplaceConversation as updateGlobalConversationCache',
107101
'setActiveStreamingThreads',
108102
'setConversationId',
109-
'scrollThreadToBottom',
110103
'setAutoRun',
111104
],
112105
],
@@ -363,18 +356,6 @@ export const maxThreadLogic = kea<maxThreadLogicType>([
363356
}
364357
},
365358

366-
addMessage: (payload) => {
367-
if (isHumanMessage(payload.message) || isVisualizationMessage(payload.message)) {
368-
actions.scrollThreadToBottom()
369-
}
370-
},
371-
372-
replaceMessage: (payload) => {
373-
if (isVisualizationMessage(payload.message)) {
374-
actions.scrollThreadToBottom()
375-
}
376-
},
377-
378359
completeThreadGeneration: () => {
379360
// Update the conversation history to include the new conversation
380361
actions.loadConversationHistory({ doNotUpdateCurrentThread: true })

0 commit comments

Comments
 (0)