Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/docs/content/components/(chatbot)/conversation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ConversationDownload,
ConversationEmptyState,
ConversationScrollButton,
ConversationVirtualizedContent,
} from "@/components/ai-elements/conversation";
import {
Message,
Expand Down Expand Up @@ -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
<Conversation>
<ConversationVirtualizedContent
items={messages}
estimateSize={() => 160}
getItemKey={(message) => message.id}
>
{(message) => (
<Message from={message.role}>
<MessageContent>
{message.parts.map((part, index) =>
part.type === "text" ? (
<MessageResponse key={`${message.id}-${index}`}>
{part.text}
</MessageResponse>
) : null
)}
</MessageContent>
</Message>
)}
</ConversationVirtualizedContent>
<ConversationScrollButton />
</Conversation>
```

## 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
Expand Down Expand Up @@ -189,6 +220,50 @@ export async function POST(req: Request) {
}}
/>

### `<ConversationVirtualizedContent />`

<TypeTable
type={{
items: {
description: "Array of items to virtualize.",
type: "TItem[]",
required: true,
},
children: {
description: "Render function for each virtualized item.",
type: "(item: TItem, index: number) => 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<ConversationContentProps, "children">',
},
}}
/>

### `<ConversationEmptyState />`

<TypeTable
Expand Down
178 changes: 143 additions & 35 deletions packages/elements/__tests__/conversation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,130 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import type * as StickToBottomModule from "use-stick-to-bottom";
import type { StickToBottomContext } from "use-stick-to-bottom";

import {
Conversation,
ConversationContent,
ConversationDownload,
ConversationEmptyState,
ConversationScrollButton,
ConversationVirtualizedContent,
messagesToMarkdown,
} from "../src/conversation";

// Mock use-stick-to-bottom with module-level state using vi.hoisted
const {
mockState,
mockScrollToBottom,
StickToBottomMock,
mockState,
getMockContext,
StickToBottomContent,
StickToBottomMock,
} = vi.hoisted(() => {
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<HTMLElement | null> &
React.RefCallback<HTMLElement>;
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<HTMLDivElement>,
"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) => (
<div role="log" {...props}>
{children}
{typeof children === "function" ? children(getContext()) : children}
</div>
);

// oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping)
const StickyContent = ({ children, ...props }: MockProps) => (
<div {...props}>{children}</div>
const StickyContent = ({
children,
scrollClassName,
...props
}: MockContentProps) => (
<div
className={scrollClassName}
ref={scrollRef}
style={{ height: 400, overflow: "auto", width: 400 }}
>
<div {...props} ref={contentRef}>
{typeof children === "function" ? children(getContext()) : children}
</div>
</div>
);

return {
StickToBottomContent: StickyContent,
StickToBottomMock: StickyMock,
getMockContext: getContext,
mockScrollToBottom: scrollToBottom,
mockState: state,
};
});

// oxlint-disable-next-line typescript-eslint(consistent-type-imports)
vi.mock<typeof import("use-stick-to-bottom")>(
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<typeof StickToBottomModule>(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: {
Expand Down Expand Up @@ -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(
<Conversation>
<ConversationVirtualizedContent
estimateSize={estimateVirtualizedMessageSize}
getItemKey={getVirtualizedMessageKey}
items={messages}
overscan={1}
>
{(item) => <div>{item.content}</div>}
</ConversationVirtualizedContent>
</Conversation>
);

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(
<Conversation>
<ConversationVirtualizedContent
className="virtual-content"
estimateSize={estimateVirtualizedMessageSize}
itemClassName="virtual-item"
items={messages.slice(0, 3)}
>
{(item) => <div>{item.content}</div>}
</ConversationVirtualizedContent>
</Conversation>
);

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(<ConversationEmptyState />);
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading