From 74fc8492412fb5ac46ef30e9aa9318f4a9783dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 21:24:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=BC=9A=E5=95=86=E6=BB=9A=E5=8A=A8):=20?= =?UTF-8?q?=E8=AE=A9=E7=AC=AC=E4=BA=8C=E3=80=81=E4=B8=89=E6=AD=A5=E6=96=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=8E=E7=BB=93=E6=9E=84=E5=8C=96=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=8D=B3=E6=97=B6=E5=AE=9A=E4=BD=8D=E5=88=B0=E5=BA=95?= =?UTF-8?q?=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为圆桌滚动钩子增加强制落底能力,阶段切换和布局变化不再被平滑滚动的旧高度截断。\n- 第二步用户发送消息后立即下滑到底部,不再等待模型首个输出。\n- 经典结果绘制与大屏对话面板接入内容高度观察,新增气泡、Markdown 和工具卡继续增高时保持跟随。\n- 保留用户手动上翻时的正常阅读行为,仅在新一轮发起或关键阶段切换时强制定位。 --- .../components/DashboardChatPanel.tsx | 20 +++++++++-- .../components/Step2Panel.tsx | 35 +++++++++++++++++-- .../components/Step3ReportMode.tsx | 20 +++++++++-- .../hooks/useAutoScroll.ts | 20 ++++++++++- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx index 4baecc2a..8c6d052b 100644 --- a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx +++ b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx @@ -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(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const contentRef = useRef(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({ {/* 聊天记录 */}
-
+
+
{phase === "creating" && dialogues.length === 0 && (
@@ -209,6 +222,7 @@ export function DashboardChatPanel({ )}
)} +
{!atBottom && } diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 7e6ed06f..d5ef201a 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -225,11 +225,40 @@ export function Step2Panel(props: Step2PanelProps) { // contentRef 与 Step1Panel 同款:消息内容(markdown/代码高亮/折叠卡/write_file 预览)在 // 状态更新之后才继续长高,deps 效果抓不到,靠 ResizeObserver 跟随实际内容高度。 const step2ContentRef = useRef(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(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) => diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 7ced1f4d..f387f9c5 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -545,7 +545,21 @@ function AgentProgressPanel({ // 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。 const inputLocked = phase === "creating"; const scrollRef = useRef(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const contentRef = useRef(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({
+
{phase === "creating" && dialogues.length === 0 && (
@@ -678,6 +693,7 @@ function AgentProgressPanel({
)} +
{!atBottom && }
( 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( 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 }; }