- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染 - 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性 - MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染 - MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||
import { MessageBubble } from './MessageBubble';
|
||
import type { ChatMessage } from '../../types/protocol';
|
||
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
|
||
|
||
interface MessageListProps {
|
||
messages: ChatMessage[];
|
||
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
|
||
showThinking?: boolean;
|
||
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
||
viewKey?: string;
|
||
/** 高亮的消息 ID,点击待办项后滚动并高亮显示 */
|
||
highlightedMessageId?: string | null;
|
||
/** 主代理当前生效模型(透传给 MessageBubble 做 Task 卡片差异显示) */
|
||
effectiveModel?: { provider: string; model: string } | null;
|
||
}
|
||
|
||
export function MessageList({
|
||
messages,
|
||
onNavigateToSubAgent,
|
||
showThinking = true,
|
||
viewKey,
|
||
highlightedMessageId,
|
||
effectiveModel,
|
||
}: MessageListProps) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const isAtBottomRef = useRef(true);
|
||
const prevShowBottomRef = useRef(false);
|
||
const prevViewKeyRef = useRef(viewKey);
|
||
const viewKeyRef = useRef(viewKey);
|
||
viewKeyRef.current = viewKey;
|
||
|
||
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
|
||
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
|
||
// 不应计入 newMessageCount。
|
||
const prevMessageCountRef = useRef(messages.length);
|
||
|
||
// Per-view scroll position memory
|
||
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
|
||
|
||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||
|
||
// 虚拟化列表:只渲染视口内的消息,长对话不卡顿。
|
||
// 动态测量元素高度(消息内容长度差异大),overscan 保证滚动平滑。
|
||
const virtualizer = useVirtualizer({
|
||
count: messages.length,
|
||
getScrollElement: () => containerRef.current,
|
||
estimateSize: () => 120,
|
||
overscan: 6,
|
||
measureElement:
|
||
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
|
||
? (el) => el.getBoundingClientRect().height
|
||
: undefined,
|
||
});
|
||
|
||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
||
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
|
||
const messageIdToIndex = useMemo(() => {
|
||
const map = new Map<string, number>();
|
||
messages.forEach((m, i) => map.set(m.id, i));
|
||
return map;
|
||
}, [messages]);
|
||
|
||
// ---- scroll helpers ----
|
||
|
||
const scrollToBottom = useCallback(
|
||
(behavior: ScrollBehavior = 'smooth') => {
|
||
isAtBottomRef.current = true;
|
||
setShowScrollToBottom(false);
|
||
setNewMessageCount(0);
|
||
if (messages.length > 0) {
|
||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior });
|
||
}
|
||
},
|
||
[virtualizer, messages.length],
|
||
);
|
||
|
||
const scrollToTop = useCallback(() => {
|
||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}, []);
|
||
|
||
// ---- scroll event: track whether user is at bottom ----
|
||
|
||
const handleScroll = useCallback(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
|
||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||
const nearBottom = distanceFromBottom < 120;
|
||
|
||
// Save scroll position for current view
|
||
const key = viewKeyRef.current;
|
||
if (key) {
|
||
scrollPositionsRef.current.set(key, el.scrollTop);
|
||
}
|
||
|
||
isAtBottomRef.current = nearBottom;
|
||
|
||
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
|
||
const shouldShowBottom = distanceFromBottom > 200;
|
||
if (shouldShowBottom !== prevShowBottomRef.current) {
|
||
prevShowBottomRef.current = shouldShowBottom;
|
||
setShowScrollToBottom(shouldShowBottom);
|
||
}
|
||
|
||
// 滚回底部时清除新消息计数
|
||
if (nearBottom) {
|
||
setNewMessageCount(0);
|
||
}
|
||
}, []);
|
||
|
||
// ---- auto-scroll: handle view switches and message updates ----
|
||
|
||
useLayoutEffect(() => {
|
||
const prevKey = prevViewKeyRef.current;
|
||
const viewChanged = prevKey !== viewKey;
|
||
prevViewKeyRef.current = viewKey;
|
||
|
||
if (messages.length === 0) {
|
||
isAtBottomRef.current = true;
|
||
prevMessageCountRef.current = 0;
|
||
return;
|
||
}
|
||
|
||
if (viewChanged) {
|
||
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
||
prevMessageCountRef.current = messages.length;
|
||
const key = viewKey ?? '';
|
||
const savedPos = scrollPositionsRef.current.get(key);
|
||
if (savedPos !== undefined && containerRef.current) {
|
||
containerRef.current.scrollTop = savedPos;
|
||
const el = containerRef.current;
|
||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||
isAtBottomRef.current = distanceFromBottom < 120;
|
||
return;
|
||
}
|
||
// First time viewing this view: scroll to bottom
|
||
isAtBottomRef.current = true;
|
||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||
return;
|
||
}
|
||
|
||
// Same view, messages changed: normal auto-scroll logic
|
||
const lastMessage = messages[messages.length - 1];
|
||
const newCount = messages.length - prevMessageCountRef.current;
|
||
prevMessageCountRef.current = messages.length;
|
||
|
||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||
virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'instant' });
|
||
// 用户自己发消息或已在底部时,不需要计数
|
||
if (newCount > 0) {
|
||
setNewMessageCount(0);
|
||
}
|
||
} else if (newCount > 0) {
|
||
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
|
||
setNewMessageCount((prev) => prev + newCount);
|
||
}
|
||
}, [messages, viewKey, virtualizer]);
|
||
|
||
// ---- mount: always scroll to bottom if messages already loaded ----
|
||
|
||
useEffect(() => {
|
||
if (messages.length > 0) {
|
||
scrollToBottom('instant');
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// ---- highlight and scroll to todo message ----
|
||
|
||
useEffect(() => {
|
||
if (!highlightedMessageId) return;
|
||
const idx = messageIdToIndex.get(highlightedMessageId);
|
||
if (idx === undefined) return;
|
||
|
||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||
|
||
// 高亮 class 需等 DOM 渲染后操作
|
||
requestAnimationFrame(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
||
if (!targetElement) return;
|
||
targetElement.classList.add('todo-highlight');
|
||
setTimeout(() => {
|
||
targetElement.classList.remove('todo-highlight');
|
||
}, 2000);
|
||
});
|
||
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
|
||
|
||
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
||
// 滚动停止后或对话列宽度变化(拖拽调宽/折叠侧栏)时,换行行可能保留 120px 估值导致行重叠。
|
||
// 在这两个时机绕过门控,直接对已渲染行调用 resizeItem 强制纠正。
|
||
const remeasureRows = useCallback(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
for (const vi of virtualizer.getVirtualItems()) {
|
||
const node = container.querySelector<HTMLElement>(`[data-index="${vi.index}"]`);
|
||
if (node) {
|
||
const h = Math.round(node.getBoundingClientRect().height);
|
||
if (h > 0) virtualizer.resizeItem(vi.index, h);
|
||
}
|
||
}
|
||
}, [virtualizer]);
|
||
|
||
// 宽度变化后强制重测(含一次 rAF 补测同提交内的重 stamped 行)
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container || typeof ResizeObserver === 'undefined') return;
|
||
let lastWidth = container.clientWidth;
|
||
const ro = new ResizeObserver(() => {
|
||
const w = container.clientWidth;
|
||
if (w === lastWidth) return;
|
||
lastWidth = w;
|
||
remeasureRows();
|
||
requestAnimationFrame(remeasureRows);
|
||
});
|
||
ro.observe(container);
|
||
return () => ro.disconnect();
|
||
}, [virtualizer, remeasureRows]);
|
||
|
||
// 滚动停止后补测一次(滚动期间被门控跳过的行在此纠正)
|
||
const isScrolling = virtualizer.isScrolling;
|
||
useEffect(() => {
|
||
if (isScrolling) return;
|
||
const id = requestAnimationFrame(remeasureRows);
|
||
return () => cancelAnimationFrame(id);
|
||
}, [isScrolling, remeasureRows]);
|
||
|
||
// ---- empty state ----
|
||
|
||
if (messages.length === 0) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="text-center animate-fade-in">
|
||
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
|
||
<Sparkles className="h-8 w-8 text-[var(--accent-cyan)]" />
|
||
</div>
|
||
<h2 className="mb-2 text-xl font-semibold text-[var(--text-primary)]">开始新的对话</h2>
|
||
<p className="text-sm text-[var(--text-muted)]">在下方输入消息开始与 AI 助手聊天</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- main render ----
|
||
|
||
const virtualItems = virtualizer.getVirtualItems();
|
||
const totalSize = virtualizer.getTotalSize();
|
||
|
||
return (
|
||
<div className="relative h-full">
|
||
<div ref={containerRef} onScroll={handleScroll} className="h-full overflow-y-auto p-6">
|
||
{/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */}
|
||
<div style={{ height: `${totalSize}px`, position: 'relative' }}>
|
||
{virtualItems.map((vi) => {
|
||
const message = messages[vi.index];
|
||
return (
|
||
<div
|
||
key={message.id}
|
||
data-index={vi.index}
|
||
data-message-id={message.id}
|
||
ref={virtualizer.measureElement}
|
||
className="pb-6"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
transform: `translateY(${vi.start}px)`,
|
||
}}
|
||
>
|
||
<div className="mx-auto w-full max-w-[736px]">
|
||
<MessageBubble
|
||
message={message}
|
||
onNavigateToSubAgent={onNavigateToSubAgent}
|
||
showThinking={showThinking}
|
||
effectiveModel={effectiveModel}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 浮动导航按钮 — 底部居中并排 */}
|
||
{showScrollToBottom && (
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||
{/* 回到顶部 */}
|
||
<button
|
||
onClick={scrollToTop}
|
||
className="flex items-center gap-1.5 px-3 py-2 rounded-full
|
||
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
|
||
border border-[var(--border-color)] shadow-sm
|
||
text-[var(--text-muted)] hover:text-[var(--text-primary)]
|
||
transition-colors duration-200 ease-out
|
||
animate-fade-in"
|
||
aria-label="回到顶部"
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
<span className="text-sm text-[var(--text-secondary)]">顶部</span>
|
||
</button>
|
||
|
||
{/* 回到底部 */}
|
||
<button
|
||
onClick={() => scrollToBottom('smooth')}
|
||
className="flex items-center gap-2 px-4 py-2 rounded-full
|
||
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
|
||
border border-[var(--border-color)] shadow-sm
|
||
text-[var(--text-muted)] hover:text-[var(--text-primary)]
|
||
transition-colors duration-200 ease-out
|
||
animate-fade-in"
|
||
aria-label="回到底部"
|
||
>
|
||
<ArrowDown
|
||
className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`}
|
||
/>
|
||
{newMessageCount > 0 ? (
|
||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||
{newMessageCount} 条新消息
|
||
</span>
|
||
) : (
|
||
<span className="text-sm text-[var(--text-secondary)]">回到最新</span>
|
||
)}
|
||
{newMessageCount > 0 && (
|
||
<span className="relative flex h-2 w-2">
|
||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60" />
|
||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent-cyan)]" />
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|