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 本体(已在右侧沙箱实时渲染),用
|
* 修改输入框)。消息里不展示 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 };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user