fix(会商滚动): 让第二、三步新消息与结构化进度即时定位到底部
- 为圆桌滚动钩子增加强制落底能力,阶段切换和布局变化不再被平滑滚动的旧高度截断。\n- 第二步用户发送消息后立即下滑到底部,不再等待模型首个输出。\n- 经典结果绘制与大屏对话面板接入内容高度观察,新增气泡、Markdown 和工具卡继续增高时保持跟随。\n- 保留用户手动上翻时的正常阅读行为,仅在新一轮发起或关键阶段切换时强制定位。
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user