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 本体(已在右侧沙箱实时渲染),用
* `stripReportFence` 替换成一句提示。
*/
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
CheckCircle,
Loader2,
@@ -64,7 +64,19 @@ export function DashboardChatPanel({
}: DashboardChatPanelProps) {
const inputLocked = phase === "creating";
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 speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
@@ -156,7 +168,8 @@ export function DashboardChatPanel({
{/* 聊天记录 */}
<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 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -209,6 +222,7 @@ export function DashboardChatPanel({
)}
</div>
)}
</div>
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
@@ -225,11 +225,40 @@ export function Step2Panel(props: Step2PanelProps) {
// contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在
// 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。
const step2ContentRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
const {
atBottom: step2AtBottom,
scrollToBottom: scrollStep2ToBottom,
forceScrollToBottom: forceStep2ToBottom,
} =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues], {
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。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings();
@@ -270,8 +299,8 @@ export function Step2Panel(props: Step2PanelProps) {
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => {
scrollStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
forceStep2ToBottom();
}, [forceStep2ToBottom, isParallelRunning, selectedParallelSeats]);
const resolveAgentName = useCallback(
(idOrName: string) =>
@@ -545,7 +545,21 @@ function AgentProgressPanel({
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
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。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -605,8 +619,9 @@ function AgentProgressPanel({
<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"
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 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -678,6 +693,7 @@ function AgentProgressPanel({
</button>
</div>
)}
</div>
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form
@@ -28,6 +28,8 @@ export function useAutoScroll<T extends HTMLElement>(
atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void;
/** Immediately jump to the bottom and resume auto-follow (for phase transitions). */
forceScrollToBottom: () => void;
} {
const threshold = options.threshold ?? 32;
const contentRef = options.contentRef;
@@ -187,5 +189,21 @@ export function useAutoScroll<T extends HTMLElement>(
setAtBottom(true);
}, [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 };
}