Smooth WebUI streaming with state-driven viewport motion (#4696)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user