fix(会商滚动): 让第二、三步新消息与结构化进度即时定位到底部

- 为圆桌滚动钩子增加强制落底能力,阶段切换和布局变化不再被平滑滚动的旧高度截断。\n- 第二步用户发送消息后立即下滑到底部,不再等待模型首个输出。\n- 经典结果绘制与大屏对话面板接入内容高度观察,新增气泡、Markdown 和工具卡继续增高时保持跟随。\n- 保留用户手动上翻时的正常阅读行为,仅在新一轮发起或关键阶段切换时强制定位。
This commit is contained in:
董宏凯 committed 2026-08-30 21:24:08 +08:00
1 parent dd560ceb46
commit 74fc849241
4 files changed
+86 -9

No files matched your search

@@ -6,7 +6,7 @@
* 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用 * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用
* `stripReportFence` 替换成一句提示。 * `stripReportFence` 替换成一句提示。
*/ */
import { useMemo, useRef, useState, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { import {
CheckCircle, CheckCircle,
Loader2, Loader2,
@@ -64,7 +64,19 @@ export function DashboardChatPanel({
}: DashboardChatPanelProps) { }: DashboardChatPanelProps) {
const inputLocked = phase === "creating"; const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, phase],
{ contentRef },
);
const previousDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > previousDialogueCountRef.current) {
forceScrollToBottom();
}
previousDialogueCountRef.current = dialogues.length;
}, [dialogues.length, forceScrollToBottom]);
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues); const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
@@ -156,7 +168,8 @@ export function DashboardChatPanel({
{/* 聊天记录 */} {/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white"> <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white">
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -209,6 +222,7 @@ export function DashboardChatPanel({
)} )}
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
@@ -225,11 +225,40 @@ export function Step2Panel(props: Step2PanelProps) {
// contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在 // contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在
// 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。 // 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。
const step2ContentRef = useRef<HTMLDivElement>(null); const step2ContentRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = const {
atBottom: step2AtBottom,
scrollToBottom: scrollStep2ToBottom,
forceScrollToBottom: forceStep2ToBottom,
} =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues], { useAutoScroll(step2ScrollRef, [step2RoundtableDialogues], {
contentRef: step2ContentRef, contentRef: step2ContentRef,
}); });
const latestUserDialogueId = useMemo(() => {
for (let index = step2RoundtableDialogues.length - 1; index >= 0; index -= 1) {
const dialogue = step2RoundtableDialogues[index];
if (dialogue.avatarType === "human") {
return dialogue.id;
}
}
return null;
}, [step2RoundtableDialogues]);
const previousUserDialogueIdRef = useRef<number | null>(latestUserDialogueId);
// 用户消息入列后立即定位到底部,不等待后续模型流式消息触发滚动。
useEffect(() => {
if (
latestUserDialogueId === null ||
latestUserDialogueId === previousUserDialogueIdRef.current
) {
return;
}
previousUserDialogueIdRef.current = latestUserDialogueId;
const frameId = window.requestAnimationFrame(forceStep2ToBottom);
return () => window.cancelAnimationFrame(frameId);
}, [forceStep2ToBottom, latestUserDialogueId]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。 // 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
@@ -270,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) {
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => { useEffect(() => {
scrollStep2ToBottom(); forceStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]); }, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]);
const resolveAgentName = useCallback( const resolveAgentName = useCallback(
(idOrName: string) => (idOrName: string) =>
@@ -545,7 +545,21 @@ function AgentProgressPanel({
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。 // 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating"; const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom, forceScrollToBottom } = useAutoScroll(
scrollRef,
[dialogues, phase],
{ contentRef },
);
// 新的绘制/修改气泡一出现就定位到最新进度;后续 Markdown、工具卡高度变化
// 由 contentRef 的 ResizeObserver 持续跟随。
const previousDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > previousDialogueCountRef.current) {
forceScrollToBottom();
}
previousDialogueCountRef.current = dialogues.length;
}, [dialogues.length, forceScrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -605,8 +619,9 @@ function AgentProgressPanel({
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white" className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
> >
<div ref={contentRef} className="flex min-h-full flex-col space-y-4 p-4">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -678,6 +693,7 @@ function AgentProgressPanel({
</button> </button>
</div> </div>
)} )}
</div>
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form <form
@@ -28,6 +28,8 @@ export function useAutoScroll<T extends HTMLElement>(
atBottom: boolean; atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */ /** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void; scrollToBottom: () => void;
/** Immediately jump to the bottom and resume auto-follow (for phase transitions). */
forceScrollToBottom: () => void;
} { } {
const threshold = options.threshold ?? 32; const threshold = options.threshold ?? 32;
const contentRef = options.contentRef; const contentRef = options.contentRef;
@@ -187,5 +189,21 @@ export function useAutoScroll<T extends HTMLElement>(
setAtBottom(true); setAtBottom(true);
}, [ref]); }, [ref]);
return { atBottom, scrollToBottom }; // 阶段切换/用户刚发送消息时不能依赖 smooth 动画:下一帧可能还会插入校验卡、
// Markdown 或工具步骤,动画目标会停在旧高度。这里直接写入 scrollTop,由内容
// ResizeObserver 继续接管后续增高,保证关键阶段始终落在最新底部。
const forceScrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
programmaticScrollTargetRef.current = Math.max(
0,
el.scrollHeight - el.clientHeight,
);
el.scrollTop = el.scrollHeight;
lastObservedScrollTopRef.current = el.scrollTop;
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom, forceScrollToBottom };
} }