Commit dc710cf3 authored by AI-甘富林's avatar AI-甘富林

fix(ui): keep conversation scrolled while streaming

parent 945b8992
import { useEffect, useMemo, useRef, useState } from "react";
import brandIcon from "./assets/brand-icon.png";
import type { ReactNode, CSSProperties, ChangeEvent, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import type { ReactNode, CSSProperties, ChangeEvent, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, UIEvent as ReactUIEvent } from "react";
import type {
AppConfig,
ChatAttachment,
......@@ -132,6 +132,7 @@ const HOME_CHAT_PROJECT_ID = "home-chat";
const EMPTY_SESSION_ID = "";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3;
const MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX = 80;
const MAX_TRACE_ITEMS = 60;
const HOME_EXPERT_SUGGESTION_PROJECT_IDS = new Set(["xhs", "douyin"]);
const IMAGE_ATTACHMENT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
......@@ -2224,6 +2225,9 @@ export default function App() {
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const conversationWorkspaceRef = useRef<HTMLDivElement | null>(null);
const messageListRef = useRef<HTMLDivElement | null>(null);
const shouldAutoScrollMessageListRef = useRef(true);
const messageListScrollFrameRef = useRef<number | null>(null);
const copiedTokenResetRef = useRef<number | null>(null);
const composerDragDepthRef = useRef(0);
const composerResizeDragRef = useRef<{ startY: number; startHeight: number; workspaceHeight: number } | null>(null);
......@@ -2240,6 +2244,47 @@ export default function App() {
}));
};
function isMessageListNearBottom(element = messageListRef.current): boolean {
if (!element) {
return true;
}
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
return distanceFromBottom <= MESSAGE_LIST_AUTO_SCROLL_THRESHOLD_PX;
}
function handleMessageListScroll(event: ReactUIEvent<HTMLDivElement>) {
shouldAutoScrollMessageListRef.current = isMessageListNearBottom(event.currentTarget);
}
function scrollMessageListToBottom(options: { force?: boolean; behavior?: ScrollBehavior } = {}) {
const element = messageListRef.current;
if (!element) {
return;
}
const shouldScroll = options.force || shouldAutoScrollMessageListRef.current || isMessageListNearBottom(element);
if (!shouldScroll) {
return;
}
shouldAutoScrollMessageListRef.current = true;
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current);
}
messageListScrollFrameRef.current = window.requestAnimationFrame(() => {
messageListScrollFrameRef.current = null;
const latestElement = messageListRef.current;
if (!latestElement) {
return;
}
latestElement.scrollTo({
top: latestElement.scrollHeight,
behavior: options.behavior ?? "smooth"
});
});
}
const minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize();
const catalogSkills = workspace?.skills ?? [];
......@@ -2406,6 +2451,34 @@ export default function App() {
return () => observer.disconnect();
}, [composerTextareaRatio, isConversationView]);
useEffect(() => {
return () => {
if (messageListScrollFrameRef.current) {
window.cancelAnimationFrame(messageListScrollFrameRef.current);
messageListScrollFrameRef.current = null;
}
};
}, []);
useEffect(() => {
if (!isConversationView || !visibleSessionId) {
return;
}
shouldAutoScrollMessageListRef.current = true;
scrollMessageListToBottom({ force: true, behavior: "auto" });
}, [isConversationView, visibleSessionId]);
useEffect(() => {
if (!isConversationView) {
return;
}
scrollMessageListToBottom({
behavior: sendPhase === "streaming" ? "auto" : "smooth"
});
}, [isConversationView, messageTraces, messages, sendPhase]);
useEffect(() => {
if (viewMode !== "chat" && viewMode !== "experts") {
clearComposerAttachment();
......@@ -3503,6 +3576,7 @@ export default function App() {
statusLabel: undefined,
statusDetail: undefined
}));
scrollMessageListToBottom({ behavior: "auto" });
}
if (currentStream.renderedText.length < currentStream.targetText.length) {
......@@ -3899,6 +3973,7 @@ export default function App() {
if (optimisticSessionId) {
updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
}
try {
......@@ -3926,8 +4001,10 @@ export default function App() {
}
if (!optimisticSessionId) {
updateSessionMessages(sessionId, (current) => [...current, userMessage, assistantMessage]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
} else if (optimisticSessionId !== sessionId) {
moveSessionMessages(optimisticSessionId, sessionId, [userMessageId, assistantMessageId]);
scrollMessageListToBottom({ force: true, behavior: "smooth" });
}
updateStreamSmoke(() => ({
......@@ -4721,7 +4798,7 @@ export default function App() {
);
const messageListContent = (
<div className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
<div ref={messageListRef} onScroll={handleMessageListScroll} className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment