PicoBot/web/src/components/Chat/MessageList.tsx
oudecheng 73c25e5a20 feat(web): Task 卡片差异显示子代理模型名 + 子代理完成状态转发修复
- Task/tool 卡片头部在子代理模型与主代理不同(model 或 provider 任一不同)时显示模型徽章
- useChat 放行带 subagent_task_id 的 execution_completed 消息,更新卡片 running→completed 状态
2026-08-15 15:35:32 +08:00

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useLayoutEffect, useRef, useState, useCallback } 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 滚动定位
const messageIdToIndex = useRef<Map<string, number>>(new Map());
// ---- 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.current.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, 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>
);
}
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
messageIdToIndex.current.clear();
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
// ---- 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>
);
}