fix: fix chat scroll style

main
yannyang 2026-07-30 22:13:27 +08:00
parent 7faf2cddcf
commit b9cf3bc78a
11 changed files with 151 additions and 38 deletions

View File

@ -56,7 +56,9 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is
<Button type="text" block className="agent-model-dropdown-trigger">
<span className="agent-model-dropdown-summary">{summary}</span>
<span className="agent-model-dropdown-values">
{value.length ? value.join(', ') : '未选择'}
{value.length
? value.map(id => models.find(m => m.id === id)?.model_name || id).join(', ')
: '未选择'}
</span>
<DownOutlined className="agent-model-dropdown-arrow" />
</Button>

View File

@ -3,12 +3,23 @@
overflow-y: auto;
background: var(--color-bg);
padding: 24px;
/* 恢复正向布局 */
display: flex;
flex-direction: column;
/* 解决移动端滚动流畅度 */
-webkit-overflow-scrolling: touch;
}
.messages-container {
max-width: 960px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
/* 核心:当消息较少时,通过 margin-top: auto 将内容推到底部 */
margin-top: auto;
flex-shrink: 0;
}
.chat-empty-welcome {

View File

@ -1,5 +1,5 @@
import { Divider, Tag, Avatar } from 'antd';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useLayoutEffect } from 'react';
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender';
@ -23,11 +23,14 @@ export default function ChatBody(props: {
onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string, mode: CopyMode) => void;
scrollBottom: (force?: boolean) => void;
initialScrollDoneRef: { current: boolean };
isMobile?: boolean;
}) {
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, scrollBottom, initialScrollDoneRef, isMobile } = props;
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
const bottomAnchorRef = useRef<HTMLDivElement>(null);
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
useEffect(() => {
@ -40,10 +43,52 @@ export default function ChatBody(props: {
}
}, [streaming.active, !!streaming.answerText]);
// 1. 初始加载:使用 useLayoutEffect 在浏览器绘图前尝试触底,消除跳转感
useLayoutEffect(() => {
if (messages.length > 0 && !initialScrollDoneRef.current) {
const el = bodyRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
// 注意:这里不立即设置 initialScrollDoneRef.current = true
// 留给下面的 useEffect 或 highlight 逻辑处理,确保后续第一次滚动也是 instant
}
}
}, [messages.length, bodyRef]);
// 2. 新消息到达或流式输出时的自动跟进
useEffect(() => {
if (messages.length > 0 || streaming.active) {
const isInitial = !initialScrollDoneRef.current;
scrollBottom(isInitial);
if (isInitial) {
initialScrollDoneRef.current = true;
}
}
}, [messages.length, streaming.answerText, streaming.reasoningText, streaming.active, scrollBottom, initialScrollDoneRef]);
// 3. 监听内容高度变化如图片加载、Markdown 渲染)
useEffect(() => {
const el = bodyRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
scrollBottom();
});
const container = el.querySelector('.messages-container');
if (container) {
observer.observe(container);
}
return () => observer.disconnect();
}, [scrollBottom, bodyRef]);
const isEmpty = messages.length === 0 && !streaming.active;
return (
<div ref={bodyRef} className="chat-body">
<div className="messages-container">
{messages.length === 0 && !streaming.active ? (
{isEmpty ? (
<div className="chat-empty-welcome">
<div className="chat-welcome-avatar">
{isImageUrl(agent.avatar) ? (
@ -141,6 +186,8 @@ export default function ChatBody(props: {
</div>
</div>
)}
{/* 底部锚点:用于辅助滚动定位 */}
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
</>
)}
</div>

View File

@ -1,21 +1,35 @@
@media (max-width: 768px) {
.h5-chat-shell {
/* 移除固定高度,允许跟随全局滚动容器 */
height: 100vh;
height: 100svh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.h5-chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.h5-chat-content-row {
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.h5-chat-content-row .chat-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.h5-chat-content-row .chat-body .messages-container {
padding-bottom: 2rem;
padding: 12px 16px 32px;
}
}

View File

@ -122,6 +122,8 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => {

View File

@ -113,6 +113,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => {

View File

@ -57,9 +57,11 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制'));
}}
/>
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {

View File

@ -119,19 +119,31 @@ export default function MessageItem(props: {
</Space>
)}
{message.meta?.aborted && <Tag color="orange"></Tag>}
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
]
}}
>
{isUser ? (
<Tooltip title="复制">
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
<Button
size="small"
className='actions-btn'
type="text"
icon={<CopyOutlined />}
onClick={() => onCopy?.(formattedContent, 'plain')}
/>
</Tooltip>
</Dropdown>
) : (
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
]
}}
>
<Tooltip title="复制">
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
</Tooltip>
</Dropdown>
)}
{!isUser && (
<Tooltip title="重新生成">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />

View File

@ -21,6 +21,11 @@ export function useChatData(args: {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
const loadSeqRef = useRef(0);
const initialHighlightDoneRef = useRef(false);
useEffect(() => {
initialHighlightDoneRef.current = false;
}, [agentId, roomId]);
const loadAgent = async () => {
if (!agentId) {
@ -61,18 +66,8 @@ export function useChatData(args: {
setMessages(Array.isArray(his.messages) ? his.messages : []);
setBranches(his.branches || {});
const checkScroll = () => {
if (!initialScrollDoneRef.current) {
scrollBottom(true);
initialScrollDoneRef.current = true;
} else {
scrollBottom();
}
};
requestAnimationFrame(checkScroll);
setTimeout(checkScroll, 100);
setTimeout(checkScroll, 500);
// 历史消息加载完成后的处理
// 注意:这里不再手动触发滚动,全部交给 ChatBody 的 useLayoutEffect 和 useEffect 处理
};
useEffect(() => {
@ -109,8 +104,26 @@ export function useChatData(args: {
if (!highlightId) return;
const el = document.getElementById('msg-' + highlightId);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [highlightId]);
// 如果是房间加载后的第一次高亮(通常是系统自动选中的最后一条消息)
const isFirstHighlight = !initialHighlightDoneRef.current;
// 逻辑:如果是初始加载,且该消息是最后一条消息,则不触发 scrollIntoView(start)
// 这样可以保留 ChatBody 的 scrollBottom 效果(看到消息的尾部)
const isLastMessage = messages.length > 0 && messages[messages.length - 1].id === highlightId;
if (isFirstHighlight && isLastMessage) {
initialHighlightDoneRef.current = true;
return;
}
const behavior = isFirstHighlight ? 'instant' : 'smooth';
el.scrollIntoView({ behavior, block: 'start' });
if (isFirstHighlight) {
initialHighlightDoneRef.current = true;
}
}, [highlightId, messages]);
return {
agent,

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
export function useChatScroll() {
const bodyRef = useRef<HTMLDivElement>(null);
@ -13,7 +13,7 @@ export function useChatScroll() {
const lastUserScrollTypeRef = useRef<string>('');
const attachRetryTimerRef = useRef<number | null>(null);
const scrollBottom = (force = false) => {
const scrollBottom = useCallback((force = false) => {
if (force) {
userScrollLockRef.current = false;
autoScrollRef.current = true;
@ -32,9 +32,14 @@ export function useChatScroll() {
const el = bodyRef.current;
if (!el) return;
scrollProgrammaticAtRef.current = Date.now();
el.scrollTop = el.scrollHeight;
// 恢复正向滚动scrollTop = scrollHeight
el.scrollTo({
top: el.scrollHeight,
behavior: force ? 'instant' : 'smooth'
});
});
};
}, []);
const cancelAutoScroll = (type: string) => {
userScrollLockRef.current = true;
@ -57,9 +62,12 @@ export function useChatScroll() {
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
const nextTop = el.scrollTop;
const lastTop = lastScrollTopRef.current;
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
const distance = el.scrollHeight - nextTop - el.clientHeight;
const scrollDelta = nextTop - lastTop;
// scrollDelta < 0 表示向上滚动
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
}

View File

@ -516,7 +516,7 @@ body {
}
.is-h5 .bubble {
max-width: 94%;
/* max-width: 94%; */
}
.bubble.assistant p,