feat(chat): smooth streaming output

This commit is contained in:
2026-06-10 21:12:53 +08:00
parent 7d2ae87e39
commit 224d53a04d
9 changed files with 915 additions and 85 deletions
+60 -4
View File
@@ -24,6 +24,9 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"
import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
@@ -35,6 +38,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false);
const theme = useTheme();
@@ -123,9 +129,53 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
bottomRef.current?.scrollIntoView({ behavior });
}, []);
const cancelStreamingScroll = useCallback(() => {
if (streamingScrollFrameRef.current === null) return;
window.cancelAnimationFrame(streamingScrollFrameRef.current);
streamingScrollFrameRef.current = null;
}, []);
const scheduleStreamingScrollToBottom = useCallback(() => {
if (streamingScrollFrameRef.current !== null) return;
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
streamingScrollFrameRef.current = null;
const container = workspaceScrollRef.current;
if (!container || !isNearBottomRef.current) return;
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
container.scrollTop = container.scrollHeight - container.clientHeight;
});
}, []);
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
isNearBottomRef.current = isNearBottom;
}, []);
useEffect(() => {
scrollToBottom(isStreaming ? "auto" : "smooth");
}, [isStreaming, messages, scrollToBottom]);
if (isStreaming) {
if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom();
return;
}
cancelStreamingScroll();
scrollToBottom("smooth");
}, [
cancelStreamingScroll,
isStreaming,
messages,
scheduleStreamingScrollToBottom,
scrollToBottom,
]);
useEffect(
() => () => {
cancelStreamingScroll();
},
[cancelStreamingScroll],
);
useEffect(() => {
if (!open) {
@@ -140,10 +190,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
composerRef.current?.clear();
setIsHistoryOpen(false);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open, scrollToBottom]);
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return;
@@ -181,9 +233,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
composerRef.current?.clear();
window.setTimeout(() => {
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev);
@@ -377,7 +431,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
messages={messages}
isStreaming={isStreaming}
isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef}
onScrollStateChange={handleWorkspaceScrollStateChange}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}