Skip to content

Commit 892fa9f

Browse files
authored
🐛 fix: fix auto scroll (#11734)
* fix auto scroll * fix auto scroll * Update DebugInspector.tsx
1 parent b15d821 commit 892fa9f

File tree

8 files changed

+683
-80
lines changed

8 files changed

+683
-80
lines changed

src/features/Conversation/ChatList/components/AutoScroll/DebugInspector.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const DebugInspector = memo(() => {
4040
style={{
4141
background: 'rgba(0,0,0,0.9)',
4242
borderRadius: 8,
43-
bottom: 80,
43+
bottom: 135,
4444
display: 'flex',
4545
fontFamily: 'monospace',
4646
fontSize: 11,

src/features/Conversation/ChatList/components/AutoScroll/index.tsx

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ import {
88
useConversationStore,
99
virtuaListSelectors,
1010
} from '../../../store';
11-
import BackBottom from '../BackBottom';
12-
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
1311

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+
*/
1419
const AutoScroll = memo(() => {
1520
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
1621
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
@@ -31,54 +36,8 @@ const AutoScroll = memo(() => {
3136
}
3237
}, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
3338

34-
return (
35-
<div style={{ position: 'relative', width: '100%' }}>
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;
8241
});
8342

8443
AutoScroll.displayName = 'ConversationAutoScroll';

src/features/Conversation/ChatList/components/BackBottom/index.tsx

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,83 @@ import { ArrowDownIcon } from 'lucide-react';
44
import { memo } from 'react';
55
import { useTranslation } from 'react-i18next';
66

7+
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from '../AutoScroll/DebugInspector';
78
import { styles } from './style';
89

910
export interface BackBottomProps {
11+
atBottom: boolean;
1012
onScrollToBottom: () => void;
1113
visible: boolean;
1214
}
1315

14-
const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
16+
const BackBottom = memo<BackBottomProps>(({ visible, atBottom, onScrollToBottom }) => {
1517
const { t } = useTranslation('chat');
1618

1719
return (
18-
<ActionIcon
19-
className={cx(styles.container, visible && styles.visible)}
20-
glass
21-
icon={ArrowDownIcon}
22-
onClick={onScrollToBottom}
23-
size={{
24-
blockSize: 36,
25-
borderRadius: 36,
26-
size: 18,
27-
}}
28-
title={t('backToBottom')}
29-
variant={'outlined'}
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+
</>
3184
);
3285
});
3386

src/features/Conversation/ChatList/components/VirtualizedList.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef
55
import { VList, type VListHandle } from 'virtua';
66

77
import WideScreenContainer from '../../../WideScreenContainer';
8-
import { useConversationStore, virtuaListSelectors } from '../../store';
8+
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
9+
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
910
import AutoScroll from './AutoScroll';
1011
import DebugInspector, {
1112
AT_BOTTOM_THRESHOLD,
1213
OPEN_DEV_INSPECTOR,
1314
} from './AutoScroll/DebugInspector';
15+
import BackBottom from './BackBottom';
1416

1517
interface VirtualizedListProps {
1618
dataSource: string[];
@@ -24,7 +26,6 @@ interface VirtualizedListProps {
2426
*/
2527
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
2628
const virtuaRef = useRef<VListHandle>(null);
27-
const prevDataLengthRef = useRef(dataSource.length);
2829
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2930

3031
// Store actions
@@ -112,15 +113,18 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
112113
};
113114
}, [resetVisibleItems]);
114115

115-
// Auto scroll to bottom when new messages arrive
116-
useEffect(() => {
117-
const shouldScroll = dataSource.length > prevDataLengthRef.current;
118-
prevDataLengthRef.current = dataSource.length;
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';
119120

120-
if (shouldScroll && virtuaRef.current) {
121-
virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
122-
}
123-
}, [dataSource.length]);
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+
});
124128

125129
// Scroll to bottom on initial render
126130
useEffect(() => {
@@ -129,8 +133,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
129133
}
130134
}, []);
131135

136+
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
137+
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
138+
132139
return (
133-
<>
140+
<div style={{ height: '100%', position: 'relative' }}>
134141
{/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
135142
{OPEN_DEV_INSPECTOR && <DebugInspector />}
136143
<VList
@@ -143,28 +150,32 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
143150
>
144151
{(messageId, index): ReactElement => {
145152
const isAgentCouncil = messageId.includes('agentCouncil');
153+
const isLastItem = index === dataSource.length - 1;
146154
const content = itemContent(index, messageId);
147-
const isLast = index === dataSource.length - 1;
148155

149156
if (isAgentCouncil) {
150157
// AgentCouncil needs full width for horizontal scroll
151158
return (
152159
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
153160
{content}
154-
{isLast && <AutoScroll />}
161+
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
162+
{isLastItem && <AutoScroll />}
155163
</div>
156164
);
157165
}
158166

159167
return (
160168
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
161169
{content}
162-
{isLast && <AutoScroll />}
170+
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
171+
{isLastItem && <AutoScroll />}
163172
</WideScreenContainer>
164173
);
165174
}}
166175
</VList>
167-
</>
176+
{/* BackBottom 放在 VList 外面,这样无论滚动到哪里都能看到 */}
177+
<BackBottom atBottom={atBottom} onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
178+
</div>
168179
);
169180
}, isEqual);
170181

0 commit comments

Comments
 (0)