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
+63 -42
View File
@@ -31,7 +31,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
import { parseQuotedUserMessage } from "@/lib/user-message-quote";
@@ -239,13 +239,19 @@ export function MessageBubble({
const showCopyButton = showCopyAction && showAssistantActions;
const showForkButton = showAssistantActions && !!onForkFromHere;
const forkLabel = t("message.forkFromHere");
const latencyMs = message.latencyMs;
const showLatencyFooter =
const completedAt = message.completedAt;
const completedAtLabel =
message.role === "assistant" && !message.isStreaming
? formatMessageEndTime(completedAt)
: "";
const showCompletedAt =
completedAtLabel.length > 0
&& (!empty || hasReasoning || media.length > 0);
const completedAtTitle = showCompletedAt ? fmtDateTime(completedAt) : "";
const showAssistantFooterRow = showCopyButton || showForkButton || showCompletedAt;
const showAssistantFooterSlot =
message.role === "assistant"
&& latencyMs != null
&& !message.isStreaming
&& (!empty || hasReasoning || media.length > 0);
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
@@ -266,52 +272,67 @@ export function MessageBubble({
/>
) : null}
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
{/* A mode switch rebuilds Streamdown's subtree and moves the scroll anchor. */}
<MarkdownText
streaming={!!message.isStreaming}
preserveStreamingLayout
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
</div>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<MessageCopyButton content={message.content} />
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkArrowIcon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</>
)}
{showAssistantFooterSlot ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div
data-assistant-footer
data-state={showAssistantFooterRow ? "visible" : "reserved"}
aria-hidden={showAssistantFooterRow ? undefined : true}
className={cn(
"mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground",
"transition-opacity duration-300 ease-out motion-reduce:transition-none",
showAssistantFooterRow
? "opacity-100"
: "pointer-events-none opacity-0",
)}
>
{showCopyButton ? (
<MessageCopyButton content={message.content} />
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkArrowIcon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showCompletedAt ? (
<time
data-assistant-completed-at
dateTime={new Date(completedAt!).toISOString()}
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={completedAtTitle}
>
{completedAtLabel}
</time>
) : null}
</div>
</TooltipProvider>
) : null}
</div>
);
}