Optimize WebUI streaming and long history rendering

Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions.

Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
This commit is contained in:
Xubin Ren
2026-05-17 17:04:57 +08:00
parent 175b58e259
commit e5be4dac7a
30 changed files with 1551 additions and 282 deletions
+52 -38
View File
@@ -1,12 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Suspense, lazy, useCallback, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useThemeValue } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
@@ -15,30 +11,59 @@ interface CodeBlockProps {
className?: string;
}
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
function useIsDark() {
const [isDark, setIsDark] = useState(() =>
typeof document !== "undefined"
? document.documentElement.classList.contains("dark")
: true,
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
}
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
{ default: oneDark },
{ default: oneLight },
] = await Promise.all([
import("react-syntax-highlighter/dist/esm/prism-async-light"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
]);
return {
default({ language, code, isDark }: HighlightedCodeProps) {
return (
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
);
},
};
});
function PlainCodeFallback({ code }: { code: string }) {
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
>
<code>{code}</code>
</pre>
);
useEffect(() => {
const el = document.documentElement;
const observer = new MutationObserver(() => {
setIsDark(el.classList.contains("dark"));
});
observer.observe(el, { attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useIsDark();
const isDark = useThemeValue() === "dark";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@@ -86,20 +111,9 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
</div>
);
}
+26 -20
View File
@@ -167,10 +167,15 @@ function MessageMedia({
align: "left" | "right";
}) {
if (media.length === 0) return null;
const images = media
.filter((item) => item.kind === "image")
.map(({ url, name }) => ({ url, name }));
const nonImages = media.filter((item) => item.kind !== "image");
const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = [];
for (const item of media) {
if (item.kind === "image") {
images.push({ url: item.url, name: item.name });
} else {
nonImages.push(item);
}
}
return (
<div
@@ -276,13 +281,14 @@ function UserImages({
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
// placeholders (no URL) have nothing to zoom into.
const viewable = images
.map((img, i) => ({ img, i }))
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
const viewableImages = viewable.map(({ img }) => img);
const originalToViewable = new Map<number, number>(
viewable.map(({ i }, v) => [i, v]),
);
const viewableImages: UIImage[] = [];
const originalToViewable = new Map<number, number>();
for (let i = 0; i < images.length; i += 1) {
const img = images[i];
if (typeof img.url !== "string" || img.url.length === 0) continue;
originalToViewable.set(i, viewableImages.length);
viewableImages.push(img);
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -416,7 +422,7 @@ function Dot({ delay }: { delay: string }) {
);
}
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
export function StreamingLabelSheen({
children,
active,
@@ -426,21 +432,21 @@ export function StreamingLabelSheen({
active: boolean;
className?: string;
}) {
const sheenText =
typeof children === "string" || typeof children === "number"
? String(children)
: undefined;
return (
<span className={cn("relative block min-w-0 py-px", className)}>
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
<span
data-sheen-text={active ? sheenText : undefined}
className={cn(
"relative z-0 block font-medium leading-normal text-muted-foreground",
!active && "truncate",
"block w-fit max-w-full truncate font-medium leading-normal",
active ? "streaming-text-sheen" : "text-muted-foreground",
)}
>
{children}
</span>
{active ? (
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
<span className="reasoning-sheen-stripe" />
</span>
) : null}
</span>
);
}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -8,6 +8,7 @@ import type { UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
if (m.role !== "assistant" || m.kind === "trace") return false;
@@ -19,14 +20,20 @@ export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
function countToolCalls(messages: UIMessage[]): number {
let n = 0;
function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
let reasoningSteps = 0;
let toolCalls = 0;
for (const m of messages) {
if (m.kind !== "trace") continue;
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
n += Math.max(lines, 1);
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += Math.max(lines, 1);
}
}
return n;
return { reasoningSteps, toolCalls };
}
interface AgentActivityClusterProps {
@@ -46,11 +53,14 @@ export function AgentActivityCluster({
hasBodyBelow,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
const toolCalls = countToolCalls(messages);
const { reasoningSteps, toolCalls } = countActivity(messages);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
const activityScrollRef = useRef<HTMLDivElement>(null);
const activityContentRef = useRef<HTMLDivElement>(null);
const autoFollowActivityRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null);
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
@@ -79,11 +89,66 @@ export function AgentActivityCluster({
defaultValue: "{{tools}} tool calls",
});
const cancelActivityScrollFrame = useCallback(() => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = null;
}
}, []);
const scrollActivityToBottom = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, []);
const scheduleActivityScrollToBottom = useCallback(() => {
cancelActivityScrollFrame();
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
scrollActivityToBottom();
});
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
const toggleOuter = () => {
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
if (nextOpen) {
autoFollowActivityRef.current = true;
}
setUserToggledOuter(true);
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
setOuterOpenLocal(nextOpen);
};
useLayoutEffect(() => {
if (!outerExpanded || !autoFollowActivityRef.current) return;
scheduleActivityScrollToBottom();
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
useEffect(() => {
if (!outerExpanded) {
autoFollowActivityRef.current = true;
return;
}
const target = activityContentRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (autoFollowActivityRef.current) {
scheduleActivityScrollToBottom();
}
});
observer.observe(target);
return () => observer.disconnect();
}, [outerExpanded, scheduleActivityScrollToBottom]);
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
const onActivityScroll = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
}, []);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@@ -118,12 +183,15 @@ export function AgentActivityCluster({
)}
>
<div
ref={activityScrollRef}
data-testid="agent-activity-scroll"
onScroll={onActivityScroll}
className={cn(
CLUSTER_SCROLL_MAX_CLASS,
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
)}
>
<div className="flex flex-col gap-2">
<div ref={activityContentRef} className="flex flex-col gap-2">
{messages.map((m) => {
if (isReasoningOnlyAssistant(m)) {
return (
+47 -4
View File
@@ -1,3 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import {
AgentActivityCluster,
@@ -9,6 +12,8 @@ interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
}
export type DisplayUnit =
@@ -30,7 +35,7 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = [];
let i = 0;
while (i < messages.length) {
@@ -50,11 +55,49 @@ function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out;
}
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
const units = buildDisplayUnits(messages);
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
const flags = new Array<boolean>(units.length).fill(true);
let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "single" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false;
continue;
}
if (unit.type === "single" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser;
}
hasLaterUnitBeforeUser = true;
}
return flags;
}
export function ThreadMessages({
messages,
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
return (
<div className="flex w-full flex-col">
{hiddenMessageCount > 0 && onLoadEarlier ? (
<div className="mb-4 flex justify-center">
<button
type="button"
onClick={onLoadEarlier}
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
>
{t("thread.loadEarlier", {
count: hiddenMessageCount,
defaultValue: "Load earlier messages",
})}
</button>
</div>
) : null}
{units.map((unit, index) => {
const prev = units[index - 1];
const marginTop =
@@ -80,7 +123,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? isFinalAssistantSliceBeforeNextUser(units, index)
? copyFlags[index]
: true
}
/>
@@ -389,6 +389,7 @@ export function ThreadShell({
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
/>
</section>
);
+104 -5
View File
@@ -1,8 +1,17 @@
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
@@ -14,9 +23,27 @@ interface ThreadViewportProps {
emptyState?: ReactNode;
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
}
const NEAR_BOTTOM_PX = 48;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
while (
start > 0
&& isAgentActivityMember(messages[start])
&& isAgentActivityMember(messages[start - 1])
) {
start -= 1;
}
return messages.slice(start);
}
export function ThreadViewport({
messages,
@@ -25,18 +52,33 @@ export function ThreadViewport({
emptyState,
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const scrollButtonBottom = composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
@@ -77,6 +119,30 @@ export function ThreadViewport({
[cancelScheduledBottomScroll, scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current;
if (el) {
restoreScrollAfterPrependRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
}
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
}, [messages.length]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
const height = el.getBoundingClientRect().height || el.offsetHeight;
setComposerDockHeight((current) =>
Math.abs(current - height) < 1 ? current : height,
);
}, []);
useEffect(() => {
if (!atBottom) return;
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
@@ -96,8 +162,19 @@ export function ThreadViewport({
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@@ -110,6 +187,10 @@ export function ThreadViewport({
pendingConversationScrollRef.current = false;
}, [conversationKey, hasMessages, messages, scrollToBottom]);
useLayoutEffect(() => {
measureComposerDock();
}, [composer, hasMessages, measureComposerDock]);
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
useEffect(() => {
@@ -123,6 +204,14 @@ export function ThreadViewport({
return () => observer.disconnect();
}, [hasMessages, scrollToBottom]);
useEffect(() => {
const target = composerDockRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measureComposerDock());
observer.observe(target);
return () => observer.disconnect();
}, [hasMessages, measureComposerDock]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
@@ -155,11 +244,20 @@ export function ThreadViewport({
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} isStreaming={isStreaming} />
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
/>
</div>
</div>
<div className="sticky bottom-0 z-10 mt-auto bg-background">
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 mt-auto bg-background"
>
<div className="px-4 pb-3">
{composer}
</div>
@@ -183,17 +281,18 @@ export function ThreadViewport({
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{!atBottom && (
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />