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 }; }