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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user