@lobehub/lobehub 2.0.0-next.350 → 2.0.0-next.351
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +2 -2
- package/src/components/ModelSelect/index.tsx +1 -0
- package/src/features/Conversation/ChatList/components/AutoScroll/DebugInspector.tsx +1 -1
- package/src/features/Conversation/ChatList/components/AutoScroll/index.tsx +9 -50
- package/src/features/Conversation/ChatList/components/BackBottom/index.tsx +67 -14
- package/src/features/Conversation/ChatList/components/VirtualizedList.tsx +26 -15
- package/src/features/Conversation/ChatList/hooks/useScrollToUserMessage.test.ts +224 -0
- package/src/features/Conversation/ChatList/hooks/useScrollToUserMessage.ts +44 -0
- package/src/hooks/useAutoScroll.test.ts +289 -0
- package/src/hooks/useAutoScroll.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.351](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.350...v2.0.0-next.351)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-23**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **misc**: Fix auto scroll.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **misc**: Fix auto scroll, closes [#11734](https://github.com/lobehub/lobe-chat/issues/11734) ([892fa9f](https://github.com/lobehub/lobe-chat/commit/892fa9f))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.350](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.349...v2.0.0-next.350)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2026-01-23**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.351",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
"@lobehub/icons": "^4.0.2",
|
|
206
206
|
"@lobehub/market-sdk": "0.29.1",
|
|
207
207
|
"@lobehub/tts": "^4.0.2",
|
|
208
|
-
"@lobehub/ui": "^4.
|
|
208
|
+
"@lobehub/ui": "^4.28.0",
|
|
209
209
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
210
210
|
"@napi-rs/canvas": "^0.1.88",
|
|
211
211
|
"@neondatabase/serverless": "^1.0.2",
|
|
@@ -8,9 +8,14 @@ import {
|
|
|
8
8
|
useConversationStore,
|
|
9
9
|
virtuaListSelectors,
|
|
10
10
|
} from '../../../store';
|
|
11
|
-
import BackBottom from '../BackBottom';
|
|
12
|
-
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
|
|
13
11
|
|
|
12
|
+
/**
|
|
13
|
+
* AutoScroll component - handles auto-scrolling logic during AI generation.
|
|
14
|
+
* Should be placed inside the last item of VList so it only triggers when visible.
|
|
15
|
+
*
|
|
16
|
+
* This component has no visual output - it only contains the auto-scroll logic.
|
|
17
|
+
* Debug UI and BackBottom button are rendered separately outside VList.
|
|
18
|
+
*/
|
|
14
19
|
const AutoScroll = memo(() => {
|
|
15
20
|
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
|
|
16
21
|
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
|
|
@@ -31,54 +36,8 @@ const AutoScroll = memo(() => {
|
|
|
31
36
|
}
|
|
32
37
|
}, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{OPEN_DEV_INSPECTOR && (
|
|
37
|
-
<>
|
|
38
|
-
{/* Threshold 区域顶部边界线 */}
|
|
39
|
-
<div
|
|
40
|
-
style={{
|
|
41
|
-
background: atBottom ? '#22c55e' : '#ef4444',
|
|
42
|
-
height: 2,
|
|
43
|
-
left: 0,
|
|
44
|
-
opacity: 0.5,
|
|
45
|
-
pointerEvents: 'none',
|
|
46
|
-
position: 'absolute',
|
|
47
|
-
right: 0,
|
|
48
|
-
top: -AT_BOTTOM_THRESHOLD,
|
|
49
|
-
}}
|
|
50
|
-
/>
|
|
51
|
-
|
|
52
|
-
{/* Threshold 区域 mask - 显示在指示线上方 */}
|
|
53
|
-
<div
|
|
54
|
-
style={{
|
|
55
|
-
background: atBottom
|
|
56
|
-
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
|
|
57
|
-
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
|
|
58
|
-
height: AT_BOTTOM_THRESHOLD,
|
|
59
|
-
left: 0,
|
|
60
|
-
pointerEvents: 'none',
|
|
61
|
-
position: 'absolute',
|
|
62
|
-
right: 0,
|
|
63
|
-
top: -AT_BOTTOM_THRESHOLD,
|
|
64
|
-
}}
|
|
65
|
-
/>
|
|
66
|
-
|
|
67
|
-
{/* AutoScroll 位置指示线(底部) */}
|
|
68
|
-
<div
|
|
69
|
-
style={{
|
|
70
|
-
background: atBottom ? '#22c55e' : '#ef4444',
|
|
71
|
-
height: 2,
|
|
72
|
-
position: 'relative',
|
|
73
|
-
width: '100%',
|
|
74
|
-
}}
|
|
75
|
-
/>
|
|
76
|
-
</>
|
|
77
|
-
)}
|
|
78
|
-
|
|
79
|
-
<BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
39
|
+
// No visual output - this component only handles auto-scroll logic
|
|
40
|
+
return null;
|
|
82
41
|
});
|
|
83
42
|
|
|
84
43
|
AutoScroll.displayName = 'ConversationAutoScroll';
|
|
@@ -4,30 +4,83 @@ import { ArrowDownIcon } from 'lucide-react';
|
|
|
4
4
|
import { memo } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
|
|
7
|
+
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from '../AutoScroll/DebugInspector';
|
|
7
8
|
import { styles } from './style';
|
|
8
9
|
|
|
9
10
|
export interface BackBottomProps {
|
|
11
|
+
atBottom: boolean;
|
|
10
12
|
onScrollToBottom: () => void;
|
|
11
13
|
visible: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
|
|
16
|
+
const BackBottom = memo<BackBottomProps>(({ visible, atBottom, onScrollToBottom }) => {
|
|
15
17
|
const { t } = useTranslation('chat');
|
|
16
18
|
|
|
17
19
|
return (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
<>
|
|
21
|
+
{/* Debug: 底部指示线 */}
|
|
22
|
+
{OPEN_DEV_INSPECTOR && (
|
|
23
|
+
<div
|
|
24
|
+
style={{
|
|
25
|
+
bottom: 0,
|
|
26
|
+
left: 0,
|
|
27
|
+
pointerEvents: 'none',
|
|
28
|
+
position: 'absolute',
|
|
29
|
+
right: 0,
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{/* Threshold 区域顶部边界线 */}
|
|
33
|
+
<div
|
|
34
|
+
style={{
|
|
35
|
+
background: atBottom ? '#22c55e' : '#ef4444',
|
|
36
|
+
height: 2,
|
|
37
|
+
left: 0,
|
|
38
|
+
opacity: 0.5,
|
|
39
|
+
position: 'absolute',
|
|
40
|
+
right: 0,
|
|
41
|
+
top: -AT_BOTTOM_THRESHOLD,
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
|
|
45
|
+
{/* Threshold 区域 mask - 显示在指示线上方 */}
|
|
46
|
+
<div
|
|
47
|
+
style={{
|
|
48
|
+
background: atBottom
|
|
49
|
+
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
|
|
50
|
+
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
|
|
51
|
+
height: AT_BOTTOM_THRESHOLD,
|
|
52
|
+
left: 0,
|
|
53
|
+
position: 'absolute',
|
|
54
|
+
right: 0,
|
|
55
|
+
top: -AT_BOTTOM_THRESHOLD,
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
{/* AutoScroll 位置指示线(底部) */}
|
|
60
|
+
<div
|
|
61
|
+
style={{
|
|
62
|
+
background: atBottom ? '#22c55e' : '#ef4444',
|
|
63
|
+
height: 2,
|
|
64
|
+
width: '100%',
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
<ActionIcon
|
|
71
|
+
className={cx(styles.container, visible && styles.visible)}
|
|
72
|
+
glass
|
|
73
|
+
icon={ArrowDownIcon}
|
|
74
|
+
onClick={onScrollToBottom}
|
|
75
|
+
size={{
|
|
76
|
+
blockSize: 36,
|
|
77
|
+
borderRadius: 36,
|
|
78
|
+
size: 18,
|
|
79
|
+
}}
|
|
80
|
+
title={t('backToBottom')}
|
|
81
|
+
variant={'outlined'}
|
|
82
|
+
/>
|
|
83
|
+
</>
|
|
31
84
|
);
|
|
32
85
|
});
|
|
33
86
|
|
|
@@ -5,12 +5,14 @@ import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef
|
|
|
5
5
|
import { VList, type VListHandle } from 'virtua';
|
|
6
6
|
|
|
7
7
|
import WideScreenContainer from '../../../WideScreenContainer';
|
|
8
|
-
import { useConversationStore, virtuaListSelectors } from '../../store';
|
|
8
|
+
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
|
|
9
|
+
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
|
|
9
10
|
import AutoScroll from './AutoScroll';
|
|
10
11
|
import DebugInspector, {
|
|
11
12
|
AT_BOTTOM_THRESHOLD,
|
|
12
13
|
OPEN_DEV_INSPECTOR,
|
|
13
14
|
} from './AutoScroll/DebugInspector';
|
|
15
|
+
import BackBottom from './BackBottom';
|
|
14
16
|
|
|
15
17
|
interface VirtualizedListProps {
|
|
16
18
|
dataSource: string[];
|
|
@@ -24,7 +26,6 @@ interface VirtualizedListProps {
|
|
|
24
26
|
*/
|
|
25
27
|
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
|
|
26
28
|
const virtuaRef = useRef<VListHandle>(null);
|
|
27
|
-
const prevDataLengthRef = useRef(dataSource.length);
|
|
28
29
|
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
30
|
|
|
30
31
|
// Store actions
|
|
@@ -112,15 +113,18 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|
|
112
113
|
};
|
|
113
114
|
}, [resetVisibleItems]);
|
|
114
115
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
// Get the last message to check if it's a user message
|
|
117
|
+
const displayMessages = useConversationStore(dataSelectors.displayMessages);
|
|
118
|
+
const lastMessage = displayMessages.at(-1);
|
|
119
|
+
const isLastMessageFromUser = lastMessage?.role === 'user';
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// Auto scroll to user message when user sends a new message
|
|
122
|
+
// Only scroll when the new message is from the user, not when AI/agent responds
|
|
123
|
+
useScrollToUserMessage({
|
|
124
|
+
dataSourceLength: dataSource.length,
|
|
125
|
+
isLastMessageFromUser,
|
|
126
|
+
scrollToIndex: virtuaRef.current?.scrollToIndex ?? null,
|
|
127
|
+
});
|
|
124
128
|
|
|
125
129
|
// Scroll to bottom on initial render
|
|
126
130
|
useEffect(() => {
|
|
@@ -129,8 +133,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|
|
129
133
|
}
|
|
130
134
|
}, []);
|
|
131
135
|
|
|
136
|
+
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
|
|
137
|
+
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
|
|
138
|
+
|
|
132
139
|
return (
|
|
133
|
-
|
|
140
|
+
<div style={{ height: '100%', position: 'relative' }}>
|
|
134
141
|
{/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
|
|
135
142
|
{OPEN_DEV_INSPECTOR && <DebugInspector />}
|
|
136
143
|
<VList
|
|
@@ -143,15 +150,16 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|
|
143
150
|
>
|
|
144
151
|
{(messageId, index): ReactElement => {
|
|
145
152
|
const isAgentCouncil = messageId.includes('agentCouncil');
|
|
153
|
+
const isLastItem = index === dataSource.length - 1;
|
|
146
154
|
const content = itemContent(index, messageId);
|
|
147
|
-
const isLast = index === dataSource.length - 1;
|
|
148
155
|
|
|
149
156
|
if (isAgentCouncil) {
|
|
150
157
|
// AgentCouncil needs full width for horizontal scroll
|
|
151
158
|
return (
|
|
152
159
|
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
|
|
153
160
|
{content}
|
|
154
|
-
{
|
|
161
|
+
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
|
|
162
|
+
{isLastItem && <AutoScroll />}
|
|
155
163
|
</div>
|
|
156
164
|
);
|
|
157
165
|
}
|
|
@@ -159,12 +167,15 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
|
|
|
159
167
|
return (
|
|
160
168
|
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
|
|
161
169
|
{content}
|
|
162
|
-
{
|
|
170
|
+
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
|
|
171
|
+
{isLastItem && <AutoScroll />}
|
|
163
172
|
</WideScreenContainer>
|
|
164
173
|
);
|
|
165
174
|
}}
|
|
166
175
|
</VList>
|
|
167
|
-
|
|
176
|
+
{/* BackBottom 放在 VList 外面,这样无论滚动到哪里都能看到 */}
|
|
177
|
+
<BackBottom atBottom={atBottom} onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
|
|
178
|
+
</div>
|
|
168
179
|
);
|
|
169
180
|
}, isEqual);
|
|
170
181
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useScrollToUserMessage } from './useScrollToUserMessage';
|
|
5
|
+
|
|
6
|
+
describe('useScrollToUserMessage', () => {
|
|
7
|
+
describe('when user sends a new message', () => {
|
|
8
|
+
it('should scroll to user message when new message is from user', () => {
|
|
9
|
+
const scrollToIndex = vi.fn();
|
|
10
|
+
|
|
11
|
+
const { rerender } = renderHook(
|
|
12
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
13
|
+
useScrollToUserMessage({
|
|
14
|
+
dataSourceLength,
|
|
15
|
+
isLastMessageFromUser,
|
|
16
|
+
scrollToIndex,
|
|
17
|
+
}),
|
|
18
|
+
{
|
|
19
|
+
initialProps: {
|
|
20
|
+
dataSourceLength: 2,
|
|
21
|
+
isLastMessageFromUser: false,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// User sends a new message (length increases, last message is from user)
|
|
27
|
+
rerender({
|
|
28
|
+
dataSourceLength: 3,
|
|
29
|
+
isLastMessageFromUser: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(scrollToIndex).toHaveBeenCalledWith(1, { align: 'start', smooth: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should scroll to correct index when multiple user messages are sent', () => {
|
|
37
|
+
const scrollToIndex = vi.fn();
|
|
38
|
+
|
|
39
|
+
const { rerender } = renderHook(
|
|
40
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
41
|
+
useScrollToUserMessage({
|
|
42
|
+
dataSourceLength,
|
|
43
|
+
isLastMessageFromUser,
|
|
44
|
+
scrollToIndex,
|
|
45
|
+
}),
|
|
46
|
+
{
|
|
47
|
+
initialProps: {
|
|
48
|
+
dataSourceLength: 5,
|
|
49
|
+
isLastMessageFromUser: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// User sends a new message
|
|
55
|
+
rerender({
|
|
56
|
+
dataSourceLength: 6,
|
|
57
|
+
isLastMessageFromUser: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
|
|
61
|
+
expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('when AI/agent responds', () => {
|
|
66
|
+
it('should NOT scroll when new message is from AI', () => {
|
|
67
|
+
const scrollToIndex = vi.fn();
|
|
68
|
+
|
|
69
|
+
const { rerender } = renderHook(
|
|
70
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
71
|
+
useScrollToUserMessage({
|
|
72
|
+
dataSourceLength,
|
|
73
|
+
isLastMessageFromUser,
|
|
74
|
+
scrollToIndex,
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
initialProps: {
|
|
78
|
+
dataSourceLength: 2,
|
|
79
|
+
isLastMessageFromUser: true,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// AI responds (length increases, but last message is NOT from user)
|
|
85
|
+
rerender({
|
|
86
|
+
dataSourceLength: 3,
|
|
87
|
+
isLastMessageFromUser: false,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should NOT scroll when multiple agents respond in group chat', () => {
|
|
94
|
+
const scrollToIndex = vi.fn();
|
|
95
|
+
|
|
96
|
+
const { rerender } = renderHook(
|
|
97
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
98
|
+
useScrollToUserMessage({
|
|
99
|
+
dataSourceLength,
|
|
100
|
+
isLastMessageFromUser,
|
|
101
|
+
scrollToIndex,
|
|
102
|
+
}),
|
|
103
|
+
{
|
|
104
|
+
initialProps: {
|
|
105
|
+
dataSourceLength: 3,
|
|
106
|
+
isLastMessageFromUser: false,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// First agent responds
|
|
112
|
+
rerender({
|
|
113
|
+
dataSourceLength: 4,
|
|
114
|
+
isLastMessageFromUser: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
// Second agent responds
|
|
120
|
+
rerender({
|
|
121
|
+
dataSourceLength: 5,
|
|
122
|
+
isLastMessageFromUser: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('edge cases', () => {
|
|
130
|
+
it('should NOT scroll when length decreases (message deleted)', () => {
|
|
131
|
+
const scrollToIndex = vi.fn();
|
|
132
|
+
|
|
133
|
+
const { rerender } = renderHook(
|
|
134
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
135
|
+
useScrollToUserMessage({
|
|
136
|
+
dataSourceLength,
|
|
137
|
+
isLastMessageFromUser,
|
|
138
|
+
scrollToIndex,
|
|
139
|
+
}),
|
|
140
|
+
{
|
|
141
|
+
initialProps: {
|
|
142
|
+
dataSourceLength: 5,
|
|
143
|
+
isLastMessageFromUser: true,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Message deleted (length decreases)
|
|
149
|
+
rerender({
|
|
150
|
+
dataSourceLength: 4,
|
|
151
|
+
isLastMessageFromUser: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should NOT scroll when length stays the same', () => {
|
|
158
|
+
const scrollToIndex = vi.fn();
|
|
159
|
+
|
|
160
|
+
const { rerender } = renderHook(
|
|
161
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
162
|
+
useScrollToUserMessage({
|
|
163
|
+
dataSourceLength,
|
|
164
|
+
isLastMessageFromUser,
|
|
165
|
+
scrollToIndex,
|
|
166
|
+
}),
|
|
167
|
+
{
|
|
168
|
+
initialProps: {
|
|
169
|
+
dataSourceLength: 3,
|
|
170
|
+
isLastMessageFromUser: true,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Length stays the same (content update, not new message)
|
|
176
|
+
rerender({
|
|
177
|
+
dataSourceLength: 3,
|
|
178
|
+
isLastMessageFromUser: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle null scrollToIndex gracefully', () => {
|
|
185
|
+
const { rerender } = renderHook(
|
|
186
|
+
({ dataSourceLength, isLastMessageFromUser }) =>
|
|
187
|
+
useScrollToUserMessage({
|
|
188
|
+
dataSourceLength,
|
|
189
|
+
isLastMessageFromUser,
|
|
190
|
+
scrollToIndex: null,
|
|
191
|
+
}),
|
|
192
|
+
{
|
|
193
|
+
initialProps: {
|
|
194
|
+
dataSourceLength: 2,
|
|
195
|
+
isLastMessageFromUser: false,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Should not throw when scrollToIndex is null
|
|
201
|
+
expect(() => {
|
|
202
|
+
rerender({
|
|
203
|
+
dataSourceLength: 3,
|
|
204
|
+
isLastMessageFromUser: true,
|
|
205
|
+
});
|
|
206
|
+
}).not.toThrow();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should NOT scroll on initial render', () => {
|
|
210
|
+
const scrollToIndex = vi.fn();
|
|
211
|
+
|
|
212
|
+
renderHook(() =>
|
|
213
|
+
useScrollToUserMessage({
|
|
214
|
+
dataSourceLength: 5,
|
|
215
|
+
isLastMessageFromUser: true,
|
|
216
|
+
scrollToIndex,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Should not scroll on initial render even if last message is from user
|
|
221
|
+
expect(scrollToIndex).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseScrollToUserMessageOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Current data source length (number of messages)
|
|
6
|
+
*/
|
|
7
|
+
dataSourceLength: number;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the last message is from the user
|
|
10
|
+
*/
|
|
11
|
+
isLastMessageFromUser: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Function to scroll to a specific index
|
|
14
|
+
*/
|
|
15
|
+
scrollToIndex:
|
|
16
|
+
| ((index: number, options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean }) => void)
|
|
17
|
+
| null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hook to handle scrolling to user message when user sends a new message.
|
|
22
|
+
* Only triggers scroll when the new message is from the user, not when AI/agent responds.
|
|
23
|
+
*
|
|
24
|
+
* This ensures that in group chat scenarios, when multiple agents are responding,
|
|
25
|
+
* the view doesn't jump around as each agent starts speaking.
|
|
26
|
+
*/
|
|
27
|
+
export function useScrollToUserMessage({
|
|
28
|
+
dataSourceLength,
|
|
29
|
+
isLastMessageFromUser,
|
|
30
|
+
scrollToIndex,
|
|
31
|
+
}: UseScrollToUserMessageOptions): void {
|
|
32
|
+
const prevLengthRef = useRef(dataSourceLength);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const hasNewMessage = dataSourceLength > prevLengthRef.current;
|
|
36
|
+
prevLengthRef.current = dataSourceLength;
|
|
37
|
+
|
|
38
|
+
// Only scroll when user sends a new message
|
|
39
|
+
if (hasNewMessage && isLastMessageFromUser && scrollToIndex) {
|
|
40
|
+
// Scroll to the second-to-last message (user's message) with the start aligned
|
|
41
|
+
scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
|
|
42
|
+
}
|
|
43
|
+
}, [dataSourceLength, isLastMessageFromUser, scrollToIndex]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { act, renderHook } from '@testing-library/react';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { useAutoScroll } from './useAutoScroll';
|
|
8
|
+
|
|
9
|
+
describe('useAutoScroll', () => {
|
|
10
|
+
let rafCallbacks: FrameRequestCallback[] = [];
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
rafCallbacks = [];
|
|
14
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
15
|
+
rafCallbacks.push(cb);
|
|
16
|
+
return rafCallbacks.length;
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const flushRAF = () => {
|
|
25
|
+
const callbacks = [...rafCallbacks];
|
|
26
|
+
rafCallbacks = [];
|
|
27
|
+
callbacks.forEach((cb) => cb(performance.now()));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const createMockContainer = (scrollTop = 0, scrollHeight = 1000, clientHeight = 400) => {
|
|
31
|
+
return {
|
|
32
|
+
clientHeight,
|
|
33
|
+
scrollHeight,
|
|
34
|
+
scrollTop,
|
|
35
|
+
} as HTMLDivElement;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('when enabled changes from true to false (streaming ends)', () => {
|
|
39
|
+
it('should maintain scroll position when streaming ends', () => {
|
|
40
|
+
const { result, rerender } = renderHook(
|
|
41
|
+
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
|
42
|
+
{ initialProps: { content: 'initial', enabled: true } },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Simulate container scrolled to bottom (scrollTop = scrollHeight - clientHeight = 600)
|
|
46
|
+
const mockContainer = createMockContainer(600, 1000, 400);
|
|
47
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
48
|
+
|
|
49
|
+
// Trigger auto-scroll with content change while streaming
|
|
50
|
+
rerender({ content: 'updated content', enabled: true });
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
flushRAF();
|
|
54
|
+
flushRAF();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Should scroll to bottom (scrollTop = scrollHeight = 1000)
|
|
58
|
+
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
|
59
|
+
|
|
60
|
+
// Record scroll position before disabling
|
|
61
|
+
const scrollPositionBeforeDisable = mockContainer.scrollTop;
|
|
62
|
+
|
|
63
|
+
// Now simulate streaming end: enabled becomes false
|
|
64
|
+
// This is where the bug occurs - the hook stops maintaining scroll position
|
|
65
|
+
rerender({ content: 'final content', enabled: false });
|
|
66
|
+
|
|
67
|
+
act(() => {
|
|
68
|
+
flushRAF();
|
|
69
|
+
flushRAF();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// BUG TEST: After enabled becomes false, scroll position should be maintained
|
|
73
|
+
// Currently, the hook doesn't actively preserve position when disabled,
|
|
74
|
+
// which can cause scroll to reset when DOM changes occur
|
|
75
|
+
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should actively restore scroll position when DOM resets it after enabled becomes false', () => {
|
|
79
|
+
const { result, rerender } = renderHook(
|
|
80
|
+
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
|
81
|
+
{ initialProps: { content: 'initial', enabled: true } },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const mockContainer = createMockContainer(600, 1000, 400);
|
|
85
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
86
|
+
|
|
87
|
+
// Auto-scroll to bottom while streaming
|
|
88
|
+
rerender({ content: 'streaming content...', enabled: true });
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
flushRAF();
|
|
92
|
+
flushRAF();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(mockContainer.scrollTop).toBe(1000);
|
|
96
|
+
|
|
97
|
+
// Record the scroll position at bottom
|
|
98
|
+
const scrollPositionAtBottom = mockContainer.scrollTop;
|
|
99
|
+
|
|
100
|
+
// Streaming ends - enabled becomes false
|
|
101
|
+
rerender({ content: 'final content', enabled: false });
|
|
102
|
+
|
|
103
|
+
// Simulate DOM change that resets scroll position to top
|
|
104
|
+
// This happens in real browsers when content re-renders
|
|
105
|
+
mockContainer.scrollTop = 0;
|
|
106
|
+
|
|
107
|
+
act(() => {
|
|
108
|
+
flushRAF();
|
|
109
|
+
flushRAF();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// BUG: The hook should restore scroll position when enabled transitions from true to false
|
|
113
|
+
// Currently it does nothing when enabled=false, so scroll position stays at 0
|
|
114
|
+
// Expected behavior: hook should detect enabled transition and restore position
|
|
115
|
+
expect(mockContainer.scrollTop).toBe(scrollPositionAtBottom);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should preserve scroll position when user has scrolled and streaming ends', () => {
|
|
119
|
+
const { result, rerender } = renderHook(
|
|
120
|
+
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
|
|
121
|
+
{ initialProps: { content: 'initial', enabled: true } },
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Container at middle position (user scrolled up)
|
|
125
|
+
const mockContainer = createMockContainer(300, 1000, 400);
|
|
126
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
127
|
+
|
|
128
|
+
// Simulate user scroll (triggers userHasScrolled = true)
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.handleScroll();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.current.userHasScrolled).toBe(true);
|
|
134
|
+
|
|
135
|
+
const scrollPositionBeforeDisable = mockContainer.scrollTop;
|
|
136
|
+
|
|
137
|
+
// Streaming ends
|
|
138
|
+
rerender({ content: 'final content', enabled: false });
|
|
139
|
+
|
|
140
|
+
act(() => {
|
|
141
|
+
flushRAF();
|
|
142
|
+
flushRAF();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Position should remain unchanged
|
|
146
|
+
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('basic auto-scroll functionality', () => {
|
|
151
|
+
it('should auto-scroll to bottom when deps change and enabled is true', () => {
|
|
152
|
+
const { result, rerender } = renderHook(
|
|
153
|
+
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
|
|
154
|
+
{ initialProps: { content: 'initial' } },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const mockContainer = createMockContainer(0, 1000, 400);
|
|
158
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
159
|
+
|
|
160
|
+
rerender({ content: 'new content' });
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
flushRAF();
|
|
164
|
+
flushRAF();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should not auto-scroll when enabled is false', () => {
|
|
171
|
+
const { result, rerender } = renderHook(
|
|
172
|
+
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: false }),
|
|
173
|
+
{ initialProps: { content: 'initial' } },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const mockContainer = createMockContainer(100, 1000, 400);
|
|
177
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
178
|
+
const initialScrollTop = mockContainer.scrollTop;
|
|
179
|
+
|
|
180
|
+
rerender({ content: 'new content' });
|
|
181
|
+
|
|
182
|
+
act(() => {
|
|
183
|
+
flushRAF();
|
|
184
|
+
flushRAF();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(mockContainer.scrollTop).toBe(initialScrollTop);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should stop auto-scroll when user scrolls away from bottom', () => {
|
|
191
|
+
const { result, rerender } = renderHook(
|
|
192
|
+
({ content }) =>
|
|
193
|
+
useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true, threshold: 20 }),
|
|
194
|
+
{ initialProps: { content: 'initial' } },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Container NOT at bottom (distance to bottom > threshold)
|
|
198
|
+
const mockContainer = createMockContainer(100, 1000, 400);
|
|
199
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
200
|
+
|
|
201
|
+
// Simulate user scroll event
|
|
202
|
+
act(() => {
|
|
203
|
+
result.current.handleScroll();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.current.userHasScrolled).toBe(true);
|
|
207
|
+
|
|
208
|
+
// Content changes but should not auto-scroll due to user scroll lock
|
|
209
|
+
const scrollTopBeforeUpdate = mockContainer.scrollTop;
|
|
210
|
+
rerender({ content: 'new content' });
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
flushRAF();
|
|
214
|
+
flushRAF();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(mockContainer.scrollTop).toBe(scrollTopBeforeUpdate);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should reset scroll lock when resetScrollLock is called', () => {
|
|
221
|
+
const { result, rerender } = renderHook(
|
|
222
|
+
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
|
|
223
|
+
{ initialProps: { content: 'initial' } },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const mockContainer = createMockContainer(100, 1000, 400);
|
|
227
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
228
|
+
|
|
229
|
+
// User scrolls away
|
|
230
|
+
act(() => {
|
|
231
|
+
result.current.handleScroll();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.current.userHasScrolled).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Reset scroll lock
|
|
237
|
+
act(() => {
|
|
238
|
+
result.current.resetScrollLock();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(result.current.userHasScrolled).toBe(false);
|
|
242
|
+
|
|
243
|
+
// Now auto-scroll should work again
|
|
244
|
+
rerender({ content: 'new content' });
|
|
245
|
+
|
|
246
|
+
act(() => {
|
|
247
|
+
flushRAF();
|
|
248
|
+
flushRAF();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('threshold behavior', () => {
|
|
256
|
+
it('should not set userHasScrolled when at bottom within threshold', () => {
|
|
257
|
+
const { result } = renderHook(() =>
|
|
258
|
+
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Container at bottom (distance = scrollHeight - scrollTop - clientHeight = 1000 - 590 - 400 = 10 < 20)
|
|
262
|
+
const mockContainer = createMockContainer(590, 1000, 400);
|
|
263
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
264
|
+
|
|
265
|
+
act(() => {
|
|
266
|
+
result.current.handleScroll();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Should NOT set userHasScrolled because we're within threshold
|
|
270
|
+
expect(result.current.userHasScrolled).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should set userHasScrolled when scrolled beyond threshold', () => {
|
|
274
|
+
const { result } = renderHook(() =>
|
|
275
|
+
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Container NOT at bottom (distance = 1000 - 500 - 400 = 100 > 20)
|
|
279
|
+
const mockContainer = createMockContainer(500, 1000, 400);
|
|
280
|
+
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
|
|
281
|
+
|
|
282
|
+
act(() => {
|
|
283
|
+
result.current.handleScroll();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(result.current.userHasScrolled).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -67,6 +67,7 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
|
|
|
67
67
|
const ref = useRef<T | null>(null);
|
|
68
68
|
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
|
69
69
|
const isAutoScrollingRef = useRef(false);
|
|
70
|
+
const prevEnabledRef = useRef(enabled);
|
|
70
71
|
|
|
71
72
|
// Handle user scroll detection
|
|
72
73
|
const handleScroll = useCallback(() => {
|
|
@@ -91,6 +92,28 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
|
|
|
91
92
|
setUserHasScrolled(false);
|
|
92
93
|
}, []);
|
|
93
94
|
|
|
95
|
+
// Preserve scroll position when enabled transitions from true to false (streaming ends)
|
|
96
|
+
// This prevents scroll position from being lost when DOM re-renders after streaming
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const container = ref.current;
|
|
99
|
+
if (!container) return;
|
|
100
|
+
|
|
101
|
+
// Detect enabled transition from true to false
|
|
102
|
+
if (prevEnabledRef.current && !enabled) {
|
|
103
|
+
const currentScrollTop = container.scrollTop;
|
|
104
|
+
isAutoScrollingRef.current = true;
|
|
105
|
+
requestAnimationFrame(() => {
|
|
106
|
+
// Restore scroll position in case DOM changes reset it
|
|
107
|
+
container.scrollTop = currentScrollTop;
|
|
108
|
+
requestAnimationFrame(() => {
|
|
109
|
+
isAutoScrollingRef.current = false;
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
prevEnabledRef.current = enabled;
|
|
115
|
+
}, [enabled]);
|
|
116
|
+
|
|
94
117
|
// Auto scroll to bottom when deps change (unless user has scrolled or disabled)
|
|
95
118
|
useEffect(() => {
|
|
96
119
|
if (!enabled || userHasScrolled) return;
|