Skip to content

Commit e14a04e

Browse files
committed
Fix session chat auto-scroll to latest message
1 parent eaa1394 commit e14a04e

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type React from 'react'
2+
import { render, waitFor } from '@testing-library/react'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { HappyThread } from './HappyThread'
5+
import { I18nProvider } from '@/lib/i18n-context'
6+
7+
vi.mock('@assistant-ui/react', () => ({
8+
ThreadPrimitive: {
9+
Root: (props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />,
10+
Viewport: (props: { asChild?: boolean; children: React.ReactNode }) => props.asChild ? <>{props.children}</> : <div>{props.children}</div>,
11+
Messages: () => <div data-testid="thread-messages" />
12+
}
13+
}))
14+
15+
function renderThread(override: Partial<React.ComponentProps<typeof HappyThread>> = {}) {
16+
return render(
17+
<I18nProvider>
18+
<HappyThread
19+
api={{} as never}
20+
sessionId="session-1"
21+
metadata={null}
22+
agentState={null}
23+
permissionMode="default"
24+
disabled={false}
25+
onRefresh={() => {}}
26+
onRetryMessage={() => {}}
27+
onFlushPending={() => {}}
28+
onAtBottomChange={() => {}}
29+
isLoadingMessages={false}
30+
messagesWarning={null}
31+
hasMoreMessages={false}
32+
isLoadingMoreMessages={false}
33+
onLoadMore={async () => {}}
34+
pendingCount={0}
35+
rawMessagesCount={10}
36+
normalizedMessagesCount={10}
37+
messagesVersion={1}
38+
forceScrollToken={0}
39+
density="comfortable"
40+
{...override}
41+
/>
42+
</I18nProvider>
43+
)
44+
}
45+
46+
describe('HappyThread auto-scroll', () => {
47+
it('keeps viewport pinned to bottom when newer messages arrive after initial render', async () => {
48+
const view = renderThread()
49+
const viewport = view.container.querySelector('.app-scrollbar') as HTMLDivElement
50+
51+
expect(viewport).toBeTruthy()
52+
53+
Object.defineProperty(viewport, 'scrollHeight', {
54+
configurable: true,
55+
writable: true,
56+
value: 400
57+
})
58+
viewport.scrollTop = 400
59+
60+
view.rerender(
61+
<I18nProvider>
62+
<HappyThread
63+
api={{} as never}
64+
sessionId="session-1"
65+
metadata={null}
66+
agentState={null}
67+
permissionMode="default"
68+
disabled={false}
69+
onRefresh={() => {}}
70+
onRetryMessage={() => {}}
71+
onFlushPending={() => {}}
72+
onAtBottomChange={() => {}}
73+
isLoadingMessages={false}
74+
messagesWarning={null}
75+
hasMoreMessages={false}
76+
isLoadingMoreMessages={false}
77+
onLoadMore={async () => {}}
78+
pendingCount={0}
79+
rawMessagesCount={12}
80+
normalizedMessagesCount={12}
81+
messagesVersion={2}
82+
forceScrollToken={0}
83+
density="comfortable"
84+
/>
85+
</I18nProvider>
86+
)
87+
88+
Object.defineProperty(viewport, 'scrollHeight', {
89+
configurable: true,
90+
writable: true,
91+
value: 900
92+
})
93+
94+
view.rerender(
95+
<I18nProvider>
96+
<HappyThread
97+
api={{} as never}
98+
sessionId="session-1"
99+
metadata={null}
100+
agentState={null}
101+
permissionMode="default"
102+
disabled={false}
103+
onRefresh={() => {}}
104+
onRetryMessage={() => {}}
105+
onFlushPending={() => {}}
106+
onAtBottomChange={() => {}}
107+
isLoadingMessages={false}
108+
messagesWarning={null}
109+
hasMoreMessages={false}
110+
isLoadingMoreMessages={false}
111+
onLoadMore={async () => {}}
112+
pendingCount={0}
113+
rawMessagesCount={18}
114+
normalizedMessagesCount={18}
115+
messagesVersion={3}
116+
forceScrollToken={0}
117+
density="comfortable"
118+
/>
119+
</I18nProvider>
120+
)
121+
122+
await waitFor(() => {
123+
expect(viewport.scrollTop).toBe(900)
124+
})
125+
})
126+
})

web/src/components/AssistantChat/HappyThread.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,28 @@ export function HappyThread(props: {
421421
})
422422
}, [props.sessionId, props.isLoadingMessages, props.pendingCount, props.rawMessagesCount, props.messagesVersion])
423423

424+
useLayoutEffect(() => {
425+
if (initialAutoScrollPendingRef.current) {
426+
return
427+
}
428+
if (!autoScrollEnabledRef.current) {
429+
return
430+
}
431+
if (pendingScrollRef.current) {
432+
return
433+
}
434+
if (props.isLoadingMoreMessages) {
435+
return
436+
}
437+
const viewport = viewportRef.current
438+
if (!viewport) {
439+
return
440+
}
441+
442+
viewport.scrollTop = viewport.scrollHeight
443+
previousScrollTopRef.current = viewport.scrollTop
444+
}, [props.sessionId, props.messagesVersion, props.isLoadingMoreMessages])
445+
424446
useEffect(() => {
425447
isLoadingMoreRef.current = props.isLoadingMoreMessages
426448
if (props.isLoadingMoreMessages) {

0 commit comments

Comments
 (0)