diff --git a/apps/docs/content/components/(chatbot)/conversation.mdx b/apps/docs/content/components/(chatbot)/conversation.mdx index 33b82899..da6c5c47 100644 --- a/apps/docs/content/components/(chatbot)/conversation.mdx +++ b/apps/docs/content/components/(chatbot)/conversation.mdx @@ -27,6 +27,7 @@ import { ConversationDownload, ConversationEmptyState, ConversationScrollButton, + ConversationVirtualizedContent, } from "@/components/ai-elements/conversation"; import { Message, @@ -134,10 +135,40 @@ export async function POST(req: Request) { } ``` +## Virtualized Messages + +Use `ConversationVirtualizedContent` for long conversations. It keeps the regular `Conversation` auto-scroll behavior while only mounting the visible message rows. + +```tsx + + 160} + getItemKey={(message) => message.id} + > + {(message) => ( + + + {message.parts.map((part, index) => + part.type === "text" ? ( + + {part.text} + + ) : null + )} + + + )} + + + +``` + ## Features - Automatic scrolling to the bottom when new messages are added - Smooth scrolling behavior with configurable animation +- Virtualized message rendering for large conversations - Scroll button that appears when not at the bottom - Download conversation as Markdown - Responsive design with customizable padding and spacing @@ -189,6 +220,50 @@ export async function POST(req: Request) { }} /> +### `` + + ReactNode", + required: true, + }, + estimateSize: { + description: "Estimated pixel height for each item.", + type: "(item: TItem, index: number) => number", + default: "120", + }, + getItemKey: { + description: "Optional stable key for each item.", + type: "(item: TItem, index: number) => Key", + }, + gap: { + description: "Pixel gap between virtualized items.", + type: "number", + default: "32", + }, + overscan: { + description: "Number of extra items to render outside the viewport.", + type: "number", + default: "8", + }, + itemClassName: { + description: "Class name applied to each virtualized item wrapper.", + type: "string", + }, + "...props": { + description: "Any other props are spread to the content div.", + type: 'Omit', + }, + }} +/> + ### `` { const state = { isAtBottom: true }; const scrollToBottom = vi.fn(); + const stopScroll = vi.fn(); + let targetScrollTop: unknown = null; + + // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping) + const createRef = () => { + const ref = ((node: HTMLElement | null) => { + ref.current = node; + }) as React.MutableRefObject & + React.RefCallback; + ref.current = null; + return ref; + }; + + const scrollRef = createRef(); + const contentRef = createRef(); + + const getContext = (): StickToBottomContext => ({ + contentRef, + escapedFromLock: false, + isAtBottom: state.isAtBottom, + scrollRef, + scrollToBottom, + state: { + accumulated: 0, + calculatedTargetScrollTop: 0, + escapedFromLock: false, + isAtBottom: state.isAtBottom, + isNearBottom: true, + resizeDifference: 0, + scrollDifference: 0, + scrollTop: 0, + targetScrollTop: 0, + velocity: 0, + }, + stopScroll, + get targetScrollTop() { + return targetScrollTop as StickToBottomContext["targetScrollTop"]; + }, + set targetScrollTop(value: StickToBottomContext["targetScrollTop"]) { + targetScrollTop = value; + }, + }); + + interface MockProps extends Omit< + React.HTMLAttributes, + "children" + > { + children?: + | React.ReactNode + | ((context: StickToBottomContext) => React.ReactNode); + } - interface MockProps { - children?: React.ReactNode; - [key: string]: unknown; + interface MockContentProps extends MockProps { + scrollClassName?: string; } // These components must be defined inside vi.hoisted() for mock setup // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping) const StickyMock = ({ children, ...props }: MockProps) => (
- {children} + {typeof children === "function" ? children(getContext()) : children}
); // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping) - const StickyContent = ({ children, ...props }: MockProps) => ( -
{children}
+ const StickyContent = ({ + children, + scrollClassName, + ...props + }: MockContentProps) => ( +
+
+ {typeof children === "function" ? children(getContext()) : children} +
+
); return { StickToBottomContent: StickyContent, StickToBottomMock: StickyMock, + getMockContext: getContext, mockScrollToBottom: scrollToBottom, mockState: state, }; }); -// oxlint-disable-next-line typescript-eslint(consistent-type-imports) -vi.mock( - import("use-stick-to-bottom"), - () => { - const MockComponent = StickToBottomMock as typeof StickToBottomMock & { - Content: typeof StickToBottomContent; - }; - MockComponent.Content = StickToBottomContent; - - return { - StickToBottom: MockComponent, - useStickToBottomContext: () => ({ - isAtBottom: mockState.isAtBottom, - scrollToBottom: mockScrollToBottom, - }), - }; - } -); +vi.mock(import("use-stick-to-bottom"), () => { + const MockComponent = StickToBottomMock as typeof StickToBottomMock & { + Content: typeof StickToBottomContent; + }; + MockComponent.Content = StickToBottomContent; + + return { + StickToBottom: + MockComponent as unknown as typeof StickToBottomModule.StickToBottom, + useStickToBottomContext: getMockContext, + }; +}); // Custom format function for messagesToMarkdown test const customFormatMessage = (msg: { @@ -117,6 +178,60 @@ describe("conversationContent", () => { }); }); +const estimateVirtualizedMessageSize = () => 40; + +const getVirtualizedMessageKey = (item: { id: string }) => item.id; + +describe("conversationVirtualizedContent", () => { + const messages = Array.from({ length: 100 }, (_, index) => ({ + content: `Message ${index}`, + id: `message-${index}`, + })); + + it("renders visible items", async () => { + render( + + + {(item) =>
{item.content}
} +
+
+ ); + + await waitFor(() => { + expect(screen.getByText("Message 0")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Message 99")).not.toBeInTheDocument(); + }); + + it("applies custom content and item class names", async () => { + const { container } = render( + + + {(item) =>
{item.content}
} +
+
+ ); + + await waitFor(() => { + expect(screen.getByText("Message 0")).toBeInTheDocument(); + }); + + expect(container.querySelector(".virtual-content")).toBeInTheDocument(); + expect(container.querySelector(".virtual-item")).toBeInTheDocument(); + }); +}); + describe("conversationEmptyState", () => { it("renders default empty state", () => { render(); @@ -282,14 +397,7 @@ describe(messagesToMarkdown, () => { id: "multi", parts: [ { text: "Hello ", type: "text" as const }, - { - args: {}, - result: {}, - state: "result" as const, - toolInvocationId: "1", - toolName: "test", - type: "tool-invocation" as const, - }, + { type: "step-start" as const }, { text: "world", type: "text" as const }, ], role: "assistant" as const, diff --git a/packages/elements/package.json b/packages/elements/package.json index 0ac0705f..6f52e9f5 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -19,6 +19,7 @@ "@streamdown/code": "^1.1.0", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", + "@tanstack/react-virtual": "^3.13.24", "@xyflow/react": "^12.10.0", "ai": "^6.0.105", "ansi-to-react": "^6.2.6", diff --git a/packages/elements/src/conversation.tsx b/packages/elements/src/conversation.tsx index 389b8d12..fe918ca5 100644 --- a/packages/elements/src/conversation.tsx +++ b/packages/elements/src/conversation.tsx @@ -2,9 +2,10 @@ import { Button } from "@repo/shadcn-ui/components/ui/button"; import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useVirtualizer } from "@tanstack/react-virtual"; import type { UIMessage } from "ai"; import { ArrowDownIcon, DownloadIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, Key, ReactNode } from "react"; import { useCallback } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -34,6 +35,91 @@ export const ConversationContent = ({ /> ); +export type ConversationVirtualizedContentProps = Omit< + ConversationContentProps, + "children" +> & { + items: TItem[]; + children: (item: TItem, index: number) => ReactNode; + estimateSize?: (item: TItem, index: number) => number; + getItemKey?: (item: TItem, index: number) => Key; + gap?: number; + overscan?: number; + itemClassName?: string; +}; + +const DEFAULT_VIRTUALIZED_MESSAGE_SIZE = 120; +const DEFAULT_VIRTUALIZED_MESSAGE_GAP = 32; +const DEFAULT_VIRTUALIZED_OVERSCAN = 8; + +export const ConversationVirtualizedContent = ({ + className, + children, + estimateSize, + gap = DEFAULT_VIRTUALIZED_MESSAGE_GAP, + getItemKey, + itemClassName, + items, + overscan = DEFAULT_VIRTUALIZED_OVERSCAN, + ...props +}: ConversationVirtualizedContentProps) => { + const { scrollRef } = useStickToBottomContext(); + + const virtualizer = useVirtualizer({ + count: items.length, + estimateSize: (index) => { + const item = items[index]; + + if (item === undefined) { + return DEFAULT_VIRTUALIZED_MESSAGE_SIZE; + } + + return estimateSize?.(item, index) ?? DEFAULT_VIRTUALIZED_MESSAGE_SIZE; + }, + gap, + getItemKey: (index) => { + const item = items[index]; + return item === undefined || !getItemKey + ? index + : getItemKey(item, index); + }, + getScrollElement: () => scrollRef.current, + overscan, + }); + + return ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = items[virtualItem.index]; + + if (item === undefined) { + return null; + } + + return ( +
+ {children(item, virtualItem.index)} +
+ ); + })} +
+
+ ); +}; + export type ConversationEmptyStateProps = ComponentProps<"div"> & { title?: string; description?: string; diff --git a/packages/examples/src/conversation.tsx b/packages/examples/src/conversation.tsx index 4e73a885..a22e6040 100644 --- a/packages/examples/src/conversation.tsx +++ b/packages/examples/src/conversation.tsx @@ -6,6 +6,7 @@ import { ConversationDownload, ConversationEmptyState, ConversationScrollButton, + ConversationVirtualizedContent, } from "@repo/elements/conversation"; import { Message, MessageContent } from "@repo/elements/message"; import { MessageSquareIcon } from "lucide-react"; @@ -120,6 +121,10 @@ const messages: { }, ]; +const estimateMessageSize = () => 72; + +const getMessageKey = (message: { key: string }) => message.key; + const Example = () => { const [visibleMessages, setVisibleMessages] = useState< { @@ -153,21 +158,27 @@ const Example = () => { return ( - - {visibleMessages.length === 0 ? ( + {visibleMessages.length === 0 ? ( + } title="Start a conversation" /> - ) : ( - visibleMessages.map(({ key, content, role }) => ( + + ) : ( + + {({ key, content, role }) => ( {content} - )) - )} - + )} +
+ )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72f6300c..fc2c6a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: '@streamdown/mermaid': specifier: ^1.0.2 version: 1.0.2(react@19.2.3) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@xyflow/react': specifier: ^12.10.0 version: 12.10.0(@types/react@19.2.8)(immer@10.1.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2722,6 +2725,15 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8927,6 +8939,14 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/react-virtual@3.13.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@tanstack/virtual-core@3.14.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.28.6