Smooth WebUI streaming with state-driven viewport motion (#4696)

This commit is contained in:
chengyongru
2026-07-26 00:18:24 +08:00
committed by GitHub
parent 9a7debcb48
commit b0ef759e2c
28 changed files with 3202 additions and 670 deletions
+65 -29
View File
@@ -102,6 +102,18 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
function latestActiveTurnId(messages: UIMessage[]): string | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.isStreaming && message.turnId) return message.turnId;
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === "user" && message.turnId) return message.turnId;
}
return null;
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
@@ -444,8 +456,7 @@ export function ThreadShell({
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
const [submittedViewportTurnId, setSubmittedViewportTurnId] = useState<string | null>(null);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
@@ -457,6 +468,7 @@ export function ThreadShell({
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState<string | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -465,18 +477,20 @@ export function ThreadShell({
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const bottomScrolledChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId(null);
setFallbackModelName(null);
onTurnEnd?.();
}, [onTurnEnd]);
}, [chatId, onTurnEnd]);
const {
messages,
messagesReady,
isStreaming,
runStartedAt,
goalState,
@@ -504,6 +518,7 @@ export function ThreadShell({
setFilePreviewClosing(false);
setFilePreviewPath(null);
setQuotedContext(null);
setSubmittedViewportTurnId(null);
}, [historyKey]);
const handleQuoteSelection = useCallback((text: string) => {
@@ -520,6 +535,28 @@ export function ThreadShell({
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const currentGoalState = messagesReady ? goalState : undefined;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages) : null,
[displayMessages, turnActive],
);
const rememberedViewportTurnId = chatId
? activeViewportTurnByChatIdRef.current.get(chatId) ?? null
: null;
const viewportTurnId = messagesReady && turnActive
? rememberedViewportTurnId ?? restoredViewportTurnId
: null;
const activeTurnStartedHere =
viewportTurnId !== null && viewportTurnId === submittedViewportTurnId;
useEffect(() => {
if (!chatId || !messagesReady || turnActive) return;
activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId((current) =>
current === rememberedViewportTurnId ? null : current,
);
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
const filePreviewAvailabilityCache = useMemo(
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
[historyKey, token],
@@ -695,22 +732,14 @@ export function ThreadShell({
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
viewportRef.current?.cancelAutoScroll();
// A turn-end thread refresh can arrive while the viewport is easing the
// final layout change. User-driven scrolling already disables following,
// so keep an active programmatic follow alive across canonical hydration.
pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
});
}, [chatId, client, refreshHistory]);
useEffect(() => {
if (!chatId) {
bottomScrolledChatIdRef.current = null;
return;
}
if (loading || bottomScrolledChatIdRef.current === chatId) return;
bottomScrolledChatIdRef.current = chatId;
setScrollToBottomSignal((value) => value + 1);
}, [chatId, loading]);
useEffect(() => {
if (chatId) return;
setMessages(projectWebuiThreadMessages(historical));
@@ -765,8 +794,11 @@ export function ThreadShell({
}
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(pending.content, pending.images, pending.options);
const submitted = send(pending.content, pending.images, pending.options);
if (submitted && !submitted.sideChannel) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}
setBooting(false);
}, [chatId, pendingFirstTargetChatId, send]);
@@ -809,10 +841,13 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
const submitted = send(content, images, withWorkspaceScope(options));
if (chatId && submitted && !submitted.sideChannel) {
activeViewportTurnByChatIdRef.current.set(chatId, submitted.turnId);
setSubmittedViewportTurnId(submitted.turnId);
}
},
[send, withWorkspaceScope],
[chatId, send, withWorkspaceScope],
);
const handleOpenFilePreview = useCallback((path: string) => {
@@ -927,7 +962,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
isStreaming={isStreaming}
isStreaming={turnActive}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
@@ -950,8 +985,8 @@ export function ThreadShell({
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt}
goalState={goalState}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
@@ -969,7 +1004,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
isStreaming={turnActive}
placeholder={
booting
? t("thread.composer.placeholderOpening")
@@ -990,9 +1025,9 @@ export function ThreadShell({
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
runStartedAt={runStartedAt}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={goalState}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
@@ -1048,12 +1083,13 @@ export function ThreadShell({
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
isStreaming={turnActive}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
activeTurnId={viewportTurnId}
activeTurnStartedHere={activeTurnStartedHere}
conversationKey={historyKey}
conversationReady={messagesReady}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}