# AI Chat

AI Chat — Legend List documentation.

Source: https://legend.so/list/v3/react/examples/ai-chat/

Legend List v3 documentation. Current documentation version.

Features used

-   `initialScrollAtEnd` to open the conversation at the latest message.
-   `anchoredEndSpace` to reserve space around the streaming reply so the anchored bottom edge does not jump as it grows.
-   `scrollToIndex` to jump the list to the placeholder reply that streaming will update.

example-web/src/examples/AiChatExample.tsx

[View source in legend-list](https://github.com/LegendApp/legend-list/blob/a71f2cf68b30061f72bc0735b1326c6a21e2f762/example-web/src/examples/AiChatExample.tsx)

```tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { type AiMessage, buildAiConversation, buildAssistantReply } from "@examples/chat";
import { Shell } from "./shared";

const AI_CHAT_ANCHOR_MAX_LINES = 2;
const AI_CHAT_BODY_LINE_HEIGHT = 20;
const AI_CHAT_ANCHOR_MAX_SIZE = AI_CHAT_ANCHOR_MAX_LINES * AI_CHAT_BODY_LINE_HEIGHT + 32;

function AiBubble({ message }: { message: AiMessage }) {
    const isUser = message.sender === "user";

    return (
        <div
            className={
                isUser
                    ? "mb-3 ml-auto max-w-[78%] rounded-2xl bg-[#111827] px-4 py-3 text-white shadow-sm"
                    : "mb-3 max-w-[86%] rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 shadow-sm"
            }
        >
            <div className="whitespace-pre-wrap leading-[1.5]">{message.text || "Thinking..."}</div>
            <div className={isUser ? "mt-2 text-xs text-white/70" : "mt-2 text-xs text-slate-500"}>
                {message.isPlaceholder ? "Streaming..." : message.timestampLabel}
            </div>
        </div>
    );
}

function SidebarMessages() {
    const conversation = useMemo(() => buildAiConversation(), []);
    const listRef = useRef<LegendListRef>(null);
    const nextIdRef = useRef(conversation.initialMessages.length);
    const streamTimerRef = useRef<number | undefined>(undefined);

    const [messages, setMessages] = useState<AiMessage[]>(() => conversation.initialMessages);
    const [anchorIndex, setAnchorIndex] = useState<number | undefined>(undefined);
    const [input, setInput] = useState("");
    const [showScrollToEnd, setShowScrollToEnd] = useState(false);

    const stopStreaming = useCallback(() => {
        if (streamTimerRef.current !== undefined) {
            window.clearInterval(streamTimerRef.current);
            streamTimerRef.current = undefined;
        }
    }, []);

    const updateScrollToEndVisibility = useCallback(() => {
        const state = listRef.current?.getState?.() as { isAtEnd?: boolean; isNearEnd?: boolean } | undefined;
        const isNearEnd = state?.isNearEnd ?? state?.isAtEnd;
        if (isNearEnd === undefined) {
            return;
        }

        setShowScrollToEnd(!isNearEnd);
    }, []);

    useEffect(() => {
        const frame = requestAnimationFrame(() => updateScrollToEndVisibility());
        return () => cancelAnimationFrame(frame);
    }, [messages.length, updateScrollToEndVisibility]);

    useEffect(() => stopStreaming, [stopStreaming]);

    const scrollToEnd = useCallback(() => {
        listRef.current?.scrollToEnd({ animated: true });
        setShowScrollToEnd(false);
    }, []);

    const sendPrompt = useCallback(() => {
        const trimmedInput = input.trim();
        if (!trimmedInput) {
            return;
        }

        const newPromptIndex = messages.length;
        const replyWords = buildAssistantReply(trimmedInput, nextIdRef.current).split(/(\s+)/);
        const placeholderId = `assistant-${nextIdRef.current++}`;

        stopStreaming();
        setAnchorIndex(newPromptIndex);
        setMessages((current) => [
            ...current,
            {
                id: `user-${nextIdRef.current++}`,
                sender: "user",
                text: trimmedInput,
                timestampLabel: "Now",
            },
            {
                id: placeholderId,
                isPlaceholder: true,
                sender: "assistant",
                text: "",
                timestampLabel: "Now",
            },
        ]);
        setInput("");
        scrollToEnd();

        setTimeout(() => {
            let wordIndex = 0;
            streamTimerRef.current = window.setInterval(() => {
                wordIndex += 1;
                const nextText = replyWords.slice(0, wordIndex).join("");
                setMessages((current) =>
                    current.map((message) =>
                        message.id === placeholderId
                            ? {
                                  ...message,
                                  isPlaceholder: wordIndex < replyWords.length,
                                  text: nextText,
                              }
                            : message,
                    ),
                );

                if (wordIndex >= replyWords.length) {
                    stopStreaming();
                }
            }, 20);
        }, 1000);
    }, [input, messages.length, scrollToEnd, stopStreaming]);

    return (
        <div className="flex min-h-0 grow basis-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 text-sm">
            <div className="relative min-h-0 flex-1">
                <LegendList<AiMessage>
                    anchoredEndSpace={
                        anchorIndex !== undefined
                            ? {
                                  anchorIndex,
                                  anchorMaxSize: AI_CHAT_ANCHOR_MAX_SIZE,
                                  anchorOffset: 16,
                              }
                            : undefined
                    }
                    className="no-scrollbar h-full min-h-0"
                    contentContainerClassName="px-4"
                    data={messages}
                    estimatedItemSize={520}
                    initialScrollAtEnd
                    keyExtractor={(item) => item.id}
                    maintainVisibleContentPosition
                    onScroll={updateScrollToEndVisibility}
                    recycleItems
                    ref={listRef}
                    renderItem={({ item }) => <AiBubble message={item} />}
                />

                {showScrollToEnd ? (
                    <button
                        aria-label="Scroll to end"
                        className="absolute bottom-4 right-4 flex h-11 w-11 items-center justify-center rounded-full bg-[#111827] text-white shadow-lg shadow-slate-900/25"
                        onClick={scrollToEnd}
                        title="Scroll to end"
                        type="button"
                    >
                        <svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 20 20">
                            <path
                                d="M5 8L10 13L15 8"
                                stroke="currentColor"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                strokeWidth="2.2"
                            />
                        </svg>
                    </button>
                ) : null}
            </div>

            <form
                className="flex items-center gap-2 border-t border-slate-200 bg-white p-3"
                onSubmit={(event) => {
                    event.preventDefault();
                    sendPrompt();
                }}
            >
                <input
                    className="min-w-0 flex-1 rounded-full border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none focus:border-slate-500"
                    onChange={(event) => setInput(event.currentTarget.value)}
                    placeholder="Ask about list behavior"
                    value={input}
                />
                <button
                    className="rounded-full bg-slate-950 px-5 py-3 font-semibold text-white shadow-sm"
                    type="submit"
                >
                    Send
                </button>
            </form>
        </div>
    );
}

export function AiChatExample() {
    return (
        <Shell title="AI Chat">
            <SidebarMessages />
        </Shell>
    );
}
```

[View Markdown](https://legend.so/list/v3/react/examples/ai-chat.md)[Report a documentation correction](https://github.com/LegendApp/legend-docs/issues/new?title=Docs%3A+AI+Chat&body=Page%3A+https%3A%2F%2Flegend.so%2Flist%2Fv3%2Freact%2Fexamples%2Fai-chat%0A%0AWhat+is+incorrect+or+missing%3F%0A%0AExpected+behavior+or+supporting+source%3A%0A)
