diff --git a/examples/react/chat/README.md b/examples/react/chat/README.md index fc23edc9a..94cc86a96 100644 --- a/examples/react/chat/README.md +++ b/examples/react/chat/README.md @@ -7,6 +7,11 @@ Demonstrates end-anchored virtualization for chat-style UIs: - follows appended output only when the viewport is already at the latest message - keeps streaming bottom output pinned as the last row grows +The UI is a simple ChatGPT-style layout: a sidebar with chat history and a +message pane with a composer. Data comes from an in-memory mock API +(`src/api.ts`) through TanStack Query, and assistant replies are generated and +streamed word by word, so the example needs no server or API key. + ```bash npm install npm run dev diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 10a281769..f1959bcc3 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,6 +8,7 @@ "serve": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.80.7", "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/chat/src/api.ts b/examples/react/chat/src/api.ts new file mode 100644 index 000000000..9d592d442 --- /dev/null +++ b/examples/react/chat/src/api.ts @@ -0,0 +1,224 @@ +// An in-memory stand-in for a chat backend. Seeded chats are generated on +// first read from a fixed seed, so the history is the same after a reload. + +export type Author = 'user' | 'assistant' + +export type Message = { + id: string + author: Author + text: string +} + +export type Chat = { + id: string + title: string + updatedAt: number +} + +export type MessagePage = { + messages: Message[] + // Index of the first message of this page in the full chat history. + start: number +} + +const PAGE_SIZE = 30 +const READ_LATENCY = 300 +const WRITE_LATENCY = 50 + +type Random = () => number + +function seededRandom(seed: number): Random { + return () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const pick = (random: Random, items: ReadonlyArray): T => + items[Math.floor(random() * items.length)]! + +const between = (random: Random, min: number, max: number) => + min + Math.floor(random() * (max - min + 1)) + +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +const titles = [ + 'Debounce a search input', + 'Virtualize a 10k row table', + 'Explain React Suspense', + 'Plan a weekend in Rzeszów', + 'Beginner running plan', +] + +const followUps = [ + 'Can you explain that in more detail?', + 'Show me an example.', + 'What are the trade-offs?', + 'How would I test this?', + 'Can you make it shorter?', + 'Why does that work?', + 'What if the list has 10,000 items?', + 'Is there a simpler way?', + 'What would you do differently in production?', + 'Ok, and what is the next step?', + 'Can you put that in a table or a list?', + 'Thanks! One more question about the last point.', +] + +const sentences = [ + 'Start with the smallest version that works and measure it before you optimize.', + 'The main cost is usually not the code itself but the coordination around it.', + 'If the input changes often, keep the derived values out of state and compute them on render.', + 'A good default is to keep the data close to where it is used.', + 'You can always add a cache later, but removing one is much harder.', + 'Most of the time the simple solution is fast enough.', + 'Try to make the common case fast and the rare case correct.', + 'Write down the assumptions first, because they tend to change.', + 'This also makes the behavior easier to test in isolation.', + 'Watch out for edge cases at the boundaries, such as empty lists and very long values.', + 'Keep each step small enough that you can undo it without losing much.', + 'In practice, people tend to overestimate how much flexibility they need.', + 'The trade-off is a bit more setup in exchange for fewer surprises later.', + 'When in doubt, pick the option that is easier to change.', + 'It helps to look at a real example before you generalize.', + 'Consistency matters more than the exact choice.', +] + +// One to three paragraphs of random sentences, so rows have different heights. +function generateReply(random: Random, prompt: string): string { + const paragraphs = Array.from({ length: between(random, 1, 3) }, () => + Array.from({ length: between(random, 1, 4) }, () => + pick(random, sentences), + ).join(' '), + ) + return [`You asked: “${prompt}”`, ...paragraphs].join('\n\n') +} + +// Fixed replies for prompts that deserve a real answer. +const cannedReplies: Record = { + 'How do I virtualize a chat list?': [ + 'You asked: “How do I virtualize a chat list?”', + 'A chat is a virtual list anchored to the end: new messages arrive at the bottom, older history loads at the top, and replies grow while they stream. This example uses anchorTo: "end" with stable keys from getItemKey, followOnAppend with scrollEndThreshold, and scrollToEnd() with isAtEnd() for the jump button.', + "Tanner Linsley explains it in “Chat UIs Are Lists Until They Aren't”: https://tanstack.com/blog/tanstack-virtual-chat", + ].join('\n\n'), +} + +const now = Date.now() +const chats: Chat[] = titles.map((title, index) => ({ + id: `chat-${index}`, + title, + updatedAt: now - index * 5 * 60 * 60 * 1000, +})) +const messagesByChat = new Map() +let nextId = 0 + +function seedMessages(chatId: string): Message[] { + const chatIndex = chats.findIndex((chat) => chat.id === chatId) + const random = seededRandom(chatIndex + 1) + const title = chats[chatIndex]!.title + const turns = between(random, 20, 150) + const messages: Message[] = [] + for (let turn = 0; turn < turns; turn++) { + const prompt = + turn === 0 ? `${title}. Where should I start?` : pick(random, followUps) + messages.push({ id: `${chatId}-${turn}-q`, author: 'user', text: prompt }) + messages.push({ + id: `${chatId}-${turn}-a`, + author: 'assistant', + text: generateReply(random, prompt), + }) + } + return messages +} + +function getMessages(chatId: string): Message[] { + let messages = messagesByChat.get(chatId) + if (!messages) { + messages = seedMessages(chatId) + messagesByChat.set(chatId, messages) + } + return messages +} + +function touchChat(chatId: string) { + const chat = chats.find((item) => item.id === chatId)! + chat.updatedAt = Date.now() +} + +export async function fetchChats(): Promise { + await delay(READ_LATENCY) + return [...chats].sort((a, b) => b.updatedAt - a.updatedAt) +} + +// Returns the page of messages that ends right before `before`, or the +// latest page when `before` is null. +export async function fetchMessages( + chatId: string, + before: number | null, +): Promise { + await delay(READ_LATENCY) + const messages = getMessages(chatId) + const end = before ?? messages.length + const start = Math.max(0, end - PAGE_SIZE) + return { messages: messages.slice(start, end), start } +} + +export async function createChat(firstMessage: string): Promise { + await delay(WRITE_LATENCY) + const title = + firstMessage.length > 40 + ? `${firstMessage.slice(0, 40).trim()}…` + : firstMessage + const chat: Chat = { id: `chat-new-${nextId++}`, title, updatedAt: 0 } + chats.push(chat) + messagesByChat.set(chat.id, []) + return chat +} + +export async function postMessage( + chatId: string, + text: string, +): Promise { + await delay(WRITE_LATENCY) + const message: Message = { id: `m-${nextId++}`, author: 'user', text } + getMessages(chatId).push(message) + touchChat(chatId) + return message +} + +// Streams a generated reply word by word. `onUpdate` gets the whole message +// so far on every chunk. Returns a stop function that keeps the partial reply. +export function streamReply( + chatId: string, + prompt: string, + onUpdate: (message: Message) => void, + onDone: () => void, +): () => void { + const messages = getMessages(chatId) + const index = messages.length + const reply = cannedReplies[prompt] ?? generateReply(Math.random, prompt) + const words = reply.match(/\s*\S+/g) ?? [] + let message: Message = { id: `m-${nextId++}`, author: 'assistant', text: '' } + let position = 0 + + const intervalId = setInterval(() => { + const count = 1 + Math.floor(Math.random() * 3) + message = { + ...message, + text: message.text + words.slice(position, position + count).join(''), + } + position += count + messages[index] = message + onUpdate(message) + if (position >= words.length) { + clearInterval(intervalId) + touchChat(chatId) + onDone() + } + }, 40) + + return () => clearInterval(intervalId) +} diff --git a/examples/react/chat/src/index.css b/examples/react/chat/src/index.css index e8e379d47..6b4b61ca7 100644 --- a/examples/react/chat/src/index.css +++ b/examples/react/chat/src/index.css @@ -2,6 +2,33 @@ box-sizing: border-box; } +:root { + --bg: #ffffff; + --sidebar-bg: #f9f9f9; + --hover: #ececec; + --text: #0d0d0d; + --muted: #6b6b6b; + --border: #e5e5e5; + --bubble: #f1f1f1; + --accent: #0d0d0d; + --accent-text: #ffffff; + --sidebar-width: 260px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #212121; + --sidebar-bg: #171717; + --hover: #2f2f2f; + --text: #ececec; + --muted: #9b9b9b; + --border: #3a3a3a; + --bubble: #303030; + --accent: #ececec; + --accent-text: #0d0d0d; + } +} + html { font-family: Inter, @@ -11,8 +38,8 @@ html { BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #171717; - background: #f6f8fa; + color: var(--text); + background: var(--bg); } body { @@ -20,85 +47,335 @@ body { } button { - border: 1px solid #c6d0da; - border-radius: 6px; - background: #ffffff; - color: #171717; - cursor: pointer; font: inherit; - font-size: 13px; - padding: 7px 10px; -} - -button:hover { - background: #eef3f7; + color: inherit; + cursor: pointer; } .App { - height: 100vh; + height: 100dvh; display: grid; - grid-template-rows: auto 1fr; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); +} + +.App[data-sidebar-open='false'] { + grid-template-columns: 0 minmax(0, 1fr); } -.Toolbar { +/* Sidebar */ + +.Sidebar { + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding: 8px; +} + +.App[data-sidebar-open='false'] .Sidebar { + visibility: hidden; +} + +.SidebarHeader { align-items: center; - background: #ffffff; - border-bottom: 1px solid #d9e0e6; display: flex; - gap: 8px; justify-content: space-between; - padding: 10px 12px; + padding: 4px 4px 8px 8px; +} + +.Brand { + font-weight: 600; } -.ToolbarGroup { +.IconButton { + background: none; + border: 0; + border-radius: 8px; + display: grid; + height: 40px; + place-items: center; + width: 40px; +} + +.IconButton:hover { + background: var(--hover); +} + +.NewChat { + align-items: center; display: flex; - gap: 8px; + gap: 10px; +} + +.NewChat, +.ChatLink { + background: none; + border: 0; + border-radius: 8px; + flex: none; + overflow: hidden; + padding: 8px 10px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.NewChat:hover, +.ChatLink:hover, +.NewChat[aria-current='page'], +.ChatLink[aria-current='page'] { + background: var(--hover); } -.Status { - color: #5c6670; +.SidebarLabel { + color: var(--muted); font-size: 13px; + padding: 20px 10px 6px; } -.Shell { - display: grid; +.ChatList { + display: flex; + flex-direction: column; + font-size: 14px; + min-height: 0; + overflow-y: auto; +} + +.SidebarHint { + color: var(--muted); + font-size: 14px; + padding: 8px 10px; +} + +.Backdrop { + display: none; +} + +/* Main */ + +.Main { + display: flex; + flex-direction: column; min-height: 0; + min-width: 0; +} + +.MainHeader { + align-items: center; + display: flex; + gap: 8px; + height: 52px; + padding: 0 12px; +} + +.MainTitle { + font-weight: 500; overflow: hidden; - place-items: stretch; + text-overflow: ellipsis; + white-space: nowrap; } -.Messages { +.Pane { + flex: 1; min-height: 0; + position: relative; +} + +.Messages { + height: 100%; overflow: auto; overflow-anchor: none; - width: 100%; } +.PaneHint { + color: var(--muted); + flex: 1; + padding: 24px; + text-align: center; +} + +.HistoryLoader { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 13px; + left: 50%; + padding: 4px 12px; + position: absolute; + top: 8px; + transform: translateX(-50%); +} + +.JumpToLatest { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + bottom: 12px; + box-shadow: 0 2px 8px rgb(0 0 0 / 0.12); + display: grid; + height: 40px; + left: 50%; + place-items: center; + position: absolute; + transform: translateX(-50%); + width: 40px; +} + +/* Messages */ + .MessageRow { - padding: 6px 12px; + padding: 0 16px; } -.Bubble { - border: 1px solid #d7dee5; - border-radius: 8px; - line-height: 1.45; - max-width: min(720px, 88vw); - padding: 10px 12px; +.Message { + line-height: 1.6; + margin: 0 auto; + max-width: 768px; + padding: 12px 0; +} + +.Message-user { + display: flex; + justify-content: flex-end; +} + +.UserBubble { + background: var(--bubble); + border-radius: 18px; + max-width: 70%; + padding: 8px 16px; white-space: pre-wrap; } -.Bubble-user { - background: #e6f3ff; - margin-left: auto; +.Message-assistant { + white-space: pre-wrap; +} + +.Message-assistant a { + color: inherit; + overflow-wrap: anywhere; +} + +/* Composer */ + +.ComposerDock { + padding: 0 16px 16px; } -.Bubble-assistant { - background: #ffffff; - margin-right: auto; +.Empty { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + padding: 0 16px 15vh; } -.Meta { - color: #637081; - font-size: 12px; - margin-bottom: 4px; +.Empty h1 { + font-size: 28px; + font-weight: 500; + margin: 0 0 24px; +} + +.Suggestions { + display: flex; + flex-direction: column; + margin-top: 16px; + max-width: 768px; + width: 100%; +} + +.Suggestion { + align-items: center; + background: none; + border: 0; + border-radius: 12px; + color: var(--muted); + display: flex; + gap: 12px; + padding: 10px 20px; + text-align: left; +} + +.Suggestion span { + display: flex; +} + +.Suggestion:hover:not(:disabled) { + background: var(--hover); + color: var(--text); +} + +.Composer { + align-items: flex-end; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 28px; + box-shadow: 0 2px 12px rgb(0 0 0 / 0.06); + display: flex; + gap: 8px; + margin: 0 auto; + max-width: 768px; + padding: 10px 10px 10px 20px; + width: 100%; +} + +.Composer textarea { + background: none; + border: 0; + color: inherit; + field-sizing: content; + flex: 1; + font: inherit; + line-height: 1.5; + max-height: 200px; + outline: none; + padding: 6px 0; + resize: none; +} + +.SendButton { + background: var(--accent); + border: 0; + border-radius: 50%; + color: var(--accent-text); + display: grid; + flex: none; + height: 40px; + place-items: center; + width: 40px; +} + +.SendButton:disabled { + cursor: default; + opacity: 0.3; +} + +/* Narrow screens: the sidebar slides over the chat. */ + +@media (max-width: 768px) { + .App, + .App[data-sidebar-open='false'] { + grid-template-columns: minmax(0, 1fr); + } + + .Sidebar { + bottom: 0; + left: 0; + position: fixed; + top: 0; + width: var(--sidebar-width); + z-index: 2; + } + + .App[data-sidebar-open='true'] .Backdrop { + background: rgb(0 0 0 / 0.4); + display: block; + inset: 0; + position: fixed; + z-index: 1; + } } diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx index f37cc3d6f..0881665bf 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -1,49 +1,230 @@ -import React from 'react' +import * as React from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' +import { queryClient, useChats, useMessages, useSendMessage } from './queries' +import type { Message } from './api' import './index.css' -type Message = { - id: string - author: 'user' | 'assistant' - text: string +// Feather icon paths (MIT, feathericons.com), 24×24 strokes. +const icons = { + menu: 'M3 6h18M3 12h18M3 18h18', + close: 'M15 18l-6-6 6-6', + newChat: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z', + up: 'M12 19V5M5 12l7-7 7 7', + down: 'M12 5v14M19 12l-7 7-7-7', + stop: 'M7 7h10v10H7Z', } -const replies = [ - 'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.', - 'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.', - 'When the thread is not at the bottom, new output waits below without pulling the reader away from history.', +function Icon({ path }: { path: string }) { + return ( + + ) +} + +const suggestions = [ + { + text: 'How do I virtualize a chat list?', + icon: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z', + color: '#3b82f6', + }, + { + text: 'Explain scroll anchoring in one paragraph.', + icon: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12 22V8M5 12H2a10 10 0 0 0 20 0h-3', + color: '#f59e0b', + }, + { + text: 'What should I test in an infinite list?', + icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3', + color: '#10b981', + }, ] -const makeMessage = (index: number): Message => ({ - id: `message-${index}`, - author: index % 4 === 0 ? 'user' : 'assistant', - text: - index % 4 === 0 - ? `Can you check item ${index}?` - : `Message ${index}: ${replies[Math.abs(index) % replies.length]}`, -}) - -const initialMessages = Array.from({ length: 45 }, (_, index) => - makeMessage(index), -) +const isNarrow = () => window.matchMedia('(max-width: 768px)').matches function App() { + const [activeChatId, setActiveChatId] = React.useState(null) + const [sidebarOpen, setSidebarOpen] = React.useState(() => !isNarrow()) + const chats = useChats() + const { send, sending, streaming, stop } = useSendMessage() + const activeChat = chats.data?.find((chat) => chat.id === activeChatId) + + const openChat = (chatId: string | null) => { + setActiveChatId(chatId) + if (isNarrow()) setSidebarOpen(false) + } + + const onSend = (text: string) => { + send( + { chatId: activeChatId, text }, + { + // Keep a chat the user opened while the send was in flight. + onSuccess: ({ chatId }) => + setActiveChatId((current) => + current === activeChatId ? chatId : current, + ), + }, + ) + } + + const composer = ( + + ) + + return ( +
+ + + ) +} + +function ChatPane({ chatId }: { chatId: string }) { + const query = useMessages(chatId) + + if (query.isPending) { + return
Loading messages…
+ } + if (query.isError) { + return
Could not load messages.
+ } + + return ( + page.messages)} + loadingOlder={query.isFetchingPreviousPage} + onLoadOlder={() => { + if (query.hasPreviousPage) { + query.fetchPreviousPage({ cancelRefetch: false }) + } + }} + /> + ) +} + +function MessageList({ + messages, + loadingOlder, + onLoadOlder, +}: { + messages: Message[] + loadingOlder: boolean + onLoadOlder: () => void +}) { const parentRef = React.useRef(null) - const firstMessageIndexRef = React.useRef(0) - const nextMessageIndexRef = React.useRef(initialMessages.length) - const streamTimerRef = React.useRef(null) - const loadingHistoryRef = React.useRef(false) const lastScrollTopRef = React.useRef(0) - const [messages, setMessages] = React.useState(initialMessages) - const [loadingHistory, setLoadingHistory] = React.useState(false) - const [didInitialScroll, setDidInitialScroll] = React.useState(false) - const [autoHistoryEnabled, setAutoHistoryEnabled] = React.useState(false) + const [atEnd, setAtEnd] = React.useState(true) const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => parentRef.current, - estimateSize: () => 74, + estimateSize: () => 120, getItemKey: React.useCallback( (index: number) => messages[index]!.id, [messages], @@ -53,179 +234,177 @@ function App() { scrollEndThreshold: 80, overscan: 6, directDomUpdates: true, + // Runs after the virtualizer reads each scroll event. React's onScroll + // runs before it, so isAtEnd() there still sees the previous position. + onChange: (instance) => setAtEnd(instance.isAtEnd()), }) - const virtualItems = virtualizer.getVirtualItems() - - const prependHistory = React.useCallback(() => { - if (loadingHistoryRef.current || firstMessageIndexRef.current <= -90) { - return - } - - loadingHistoryRef.current = true - setLoadingHistory(true) - window.setTimeout(() => { - const start = firstMessageIndexRef.current - 12 - firstMessageIndexRef.current = start - setMessages((current) => [ - ...Array.from({ length: 12 }, (_, offset) => - makeMessage(start + offset), - ), - ...current, - ]) - loadingHistoryRef.current = false - setLoadingHistory(false) - }, 180) - }, []) - - const appendMessage = React.useCallback(() => { - const next = nextMessageIndexRef.current - nextMessageIndexRef.current += 1 - setMessages((current) => [...current, makeMessage(next)]) - }, []) - - const streamReply = React.useCallback(() => { - if (streamTimerRef.current !== null) return - - const id = `stream-${Date.now()}` - const chunks = [ - 'Thinking through the failure mode.', - ' The list should follow only when it was already pinned.', - ' Prepends should keep the reader anchored to the same message.', - ' Streaming output should grow without drifting off the bottom.', - ] - let chunkIndex = 0 - - setMessages((current) => [ - ...current, - { - id, - author: 'assistant', - text: '', - }, - ]) - - streamTimerRef.current = window.setInterval(() => { - setMessages((current) => - current.map((message) => - message.id === id - ? { - ...message, - text: chunks.slice(0, chunkIndex + 1).join(''), - } - : message, - ), - ) - - chunkIndex += 1 - if (chunkIndex === chunks.length && streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - streamTimerRef.current = null - } - }, 280) - }, []) - + // Start at the latest message, and jump to it again after each send, like + // most chat apps. Assistant output follows only when the reader is already + // at the latest message (followOnAppend). + const lastUserMessageId = [...messages] + .reverse() + .find((message) => message.author === 'user')?.id React.useLayoutEffect(() => { - if (didInitialScroll) return virtualizer.scrollToEnd() - setDidInitialScroll(true) - }, [didInitialScroll, virtualizer]) - - React.useEffect(() => { - const id = window.setTimeout(() => { - setAutoHistoryEnabled(true) - }, 250) - - return () => window.clearTimeout(id) - }, []) - - React.useEffect(() => { - return () => { - if (streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - } - } - }, []) + }, [lastUserMessageId, virtualizer]) return ( -
-
-
- - - - -
-
- {loadingHistory - ? 'Loading history' - : virtualizer.isAtEnd(80) - ? 'At latest' - : 'Reading history'} -
-
- -
+
+
{ + const { scrollTop } = event.currentTarget + const movingUp = scrollTop < lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + // Load only while scrolling up. Safari's rubber band reports + // negative positions past the top; ignore them. + if (movingUp && scrollTop >= 0 && scrollTop < 400) { + onLoadOlder() + } + }} + >
{ - if (!autoHistoryEnabled || virtualizer.isAtEnd(80)) return - const { scrollTop } = event.currentTarget - const movingUp = scrollTop < lastScrollTopRef.current - lastScrollTopRef.current = scrollTop - // Load only while the reader scrolls up into the top. Safari's - // rubber band reports negative positions past the top and then - // settles back down to 0; neither is the reader asking for more - // history, and loading on the settle would load twice per bounce. - if (movingUp && scrollTop >= 0 && scrollTop < 120) { - prependHistory() - } + ref={virtualizer.containerRef} + style={{ + position: 'relative', + width: '100%', }} > -
- {virtualItems.map((virtualItem) => { - const message = messages[virtualItem.index]! - - return ( -
-
-
{message.author}
- {message.text || '...'} -
-
- ) - })} -
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const message = messages[virtualItem.index]! + + return ( +
+ +
+ ) + })}
+ + {loadingOlder &&
Loading older…
} + {!atEnd && ( + + )}
) } -createRoot(document.getElementById('root')!).render() +function MessageView({ message }: { message: Message }) { + if (message.author === 'user') { + return ( +
+
{message.text}
+
+ ) + } + + return ( +
+ {message.text.split(/(https?:\/\/\S+)/).map((part, index) => + index % 2 ? ( + + {part} + + ) : ( + part + ), + )} +
+ ) +} + +function Composer({ + disabled, + streaming, + onSend, + onStop, +}: { + disabled: boolean + streaming: boolean + onSend: (text: string) => void + onStop: () => void +}) { + const [text, setText] = React.useState('') + + const submit = () => { + const value = text.trim() + if (!value || disabled || streaming) return + onSend(value) + setText('') + } + + return ( +
{ + event.preventDefault() + submit() + }} + > +