From 0def135f636587bc53f3c933ab26f9b7a58b19cd Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 09:34:53 +0200 Subject: [PATCH 1/9] docs(examples): rebuild the React chat example as a ChatGPT-style layout Replace the debug toolbar with a sidebar (new chat, chat history) and a message pane with a composer. Data comes from an in-memory mock API through TanStack Query: chats via useQuery, messages via useInfiniteQuery with older pages prepended, and sends via useMutation. Assistant replies are generated and streamed word by word into the query cache, so the example needs no server or API key. Co-Authored-By: Claude Opus 5.5 --- examples/react/chat/README.md | 5 + examples/react/chat/package.json | 1 + examples/react/chat/src/api.ts | 298 +++++++++++++++++ examples/react/chat/src/index.css | 374 ++++++++++++++++++--- examples/react/chat/src/main.tsx | 511 +++++++++++++++++++---------- examples/react/chat/src/queries.ts | 115 +++++++ pnpm-lock.yaml | 3 + 7 files changed, 1079 insertions(+), 228 deletions(-) create mode 100644 examples/react/chat/src/api.ts create mode 100644 examples/react/chat/src/queries.ts 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..c28e28a10 --- /dev/null +++ b/examples/react/chat/src/api.ts @@ -0,0 +1,298 @@ +// 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', + 'Plan a weekend in Kraków', + 'Explain React Suspense', + 'Sourdough starter schedule', + 'Postgres index for a slow query', + 'Rewrite a cover letter', + 'Compare Vite and webpack', + 'Beginner running plan', + 'Virtualize a 10k row table', + 'Name ideas for a bakery', + 'CSS grid vs flexbox', + 'Summarize a research paper', + 'Fix a flaky Playwright test', + 'Budget for a home office', + 'Explain TypeScript generics', + 'Birthday party games for kids', + 'Migrate from Redux to Zustand', + 'Learn basic Spanish phrases', + 'Design a REST pagination API', + 'Houseplants for low light', + 'Write a git commit message', + 'Explain event loop microtasks', + 'Meal prep for the week', + 'Accessible modal dialog', + 'Pick a mechanical keyboard', +] + +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 openers = [ + 'Good question.', + 'Sure, here is how I would approach it.', + 'Short answer: it depends on your constraints.', + 'Let me break it down.', + 'Here is a practical way to think about it.', + 'There are a few options.', +] + +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.', +] + +const bullets = [ + 'Keep the scope small', + 'Measure before and after the change', + 'Prefer boring, well-known tools', + 'Write one test for the happy path and one for an edge case', + 'Document the decision in one sentence', + 'Remove what you do not use', + 'Ask for feedback early', + 'Automate the step you repeat most often', +] + +const snippets = [ + '```ts\nfunction debounce(fn: (...args: T) => void, ms: number) {\n let id: ReturnType\n return (...args: T) => {\n clearTimeout(id)\n id = setTimeout(() => fn(...args), ms)\n }\n}\n```', + '```tsx\nconst virtualizer = useVirtualizer({\n count: rows.length,\n getScrollElement: () => parentRef.current,\n estimateSize: () => 35,\n})\n```', + '```sql\nCREATE INDEX CONCURRENTLY orders_customer_created_idx\n ON orders (customer_id, created_at DESC);\n```', + '```css\n.layout {\n display: grid;\n grid-template-columns: 260px 1fr;\n height: 100vh;\n}\n```', + '```bash\npnpm add @tanstack/react-query\npnpm dev\n```', +] + +const closers = [ + 'Let me know if you want me to go deeper on any of these.', + 'Want me to turn this into a checklist?', + 'I can also show a full example if that helps.', + 'Does that match what you had in mind?', +] + +function generateReply(random: Random, prompt: string): string { + const parts: string[] = [] + const quote = prompt.length > 48 ? `${prompt.slice(0, 48).trim()}…` : prompt + parts.push(`${pick(random, openers)} You asked: “${quote}”`) + + const paragraphs = between(random, 0, 3) + for (let i = 0; i < paragraphs; i++) { + const count = between(random, 2, 4) + parts.push( + Array.from({ length: count }, () => pick(random, sentences)).join(' '), + ) + } + if (random() < 0.3) { + const count = between(random, 3, 5) + parts.push( + Array.from({ length: count }, () => `- ${pick(random, bullets)}`).join( + '\n', + ), + ) + } + if (random() < 0.25) { + parts.push(pick(random, snippets)) + } + if (random() < 0.5) { + parts.push(pick(random, closers)) + } + return parts.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, starting with an empty one. 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 + let message: Message = { id: `m-${nextId++}`, author: 'assistant', text: '' } + messages.push(message) + onUpdate(message) + + const words = generateReply(Math.random, prompt).match(/\s*\S+/g) ?? [] + let position = 0 + let intervalId: ReturnType | undefined + + const timeoutId = setTimeout(() => { + 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) + }, 600) + + return () => { + clearTimeout(timeoutId) + clearInterval(intervalId) + } +} diff --git a/examples/react/chat/src/index.css b/examples/react/chat/src/index.css index e8e379d47..a9c4553f6 100644 --- a/examples/react/chat/src/index.css +++ b/examples/react/chat/src/index.css @@ -2,6 +2,35 @@ box-sizing: border-box; } +:root { + --bg: #ffffff; + --sidebar-bg: #f9f9f9; + --hover: #ececec; + --text: #0d0d0d; + --muted: #6b6b6b; + --border: #e5e5e5; + --bubble: #f1f1f1; + --code-bg: #f6f8fa; + --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; + --code-bg: #171717; + --accent: #ececec; + --accent-text: #0d0d0d; + } +} + html { font-family: Inter, @@ -11,8 +40,8 @@ html { BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #171717; - background: #f6f8fa; + color: var(--text); + background: var(--bg); } body { @@ -20,85 +49,336 @@ 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); +} + +/* Sidebar */ + +.Sidebar { + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding: 8px; } -.Toolbar { +.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; } -.ToolbarGroup { - display: flex; - gap: 8px; +.Brand { + font-weight: 600; +} + +.IconButton { + background: none; + border: 0; + border-radius: 8px; + font-size: 16px; + height: 36px; + width: 36px; } -.Status { - color: #5c6670; +.IconButton:hover { + background: var(--hover); +} + +.NewChat, +.ChatLink { + background: none; + border: 0; + border-radius: 8px; + 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); +} + +.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); + height: 36px; + left: 50%; + position: absolute; + transform: translateX(-50%); + width: 36px; +} + +/* 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; +.Prose { + margin: 0 0 12px; + white-space: pre-wrap; +} + +.Prose:last-child { + margin-bottom: 0; +} + +.Code { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + margin: 0 0 12px; + overflow-x: auto; + padding: 12px; +} + +.Typing { + display: inline-flex; + gap: 4px; + padding: 8px 0; } -.Bubble-assistant { - background: #ffffff; - margin-right: auto; +.Typing span { + animation: typing 1s infinite ease-in-out; + background: var(--muted); + border-radius: 50%; + height: 6px; + width: 6px; } -.Meta { - color: #637081; - font-size: 12px; - margin-bottom: 4px; +.Typing span:nth-child(2) { + animation-delay: 0.15s; +} + +.Typing span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes typing { + 0%, + 80%, + 100% { + opacity: 0.3; + } + 40% { + opacity: 1; + } +} + +/* Composer */ + +.ComposerDock { + padding: 0 16px 16px; +} + +.Empty { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + padding: 0 16px 15vh; +} + +.Empty h1 { + font-size: 28px; + font-weight: 500; + margin: 0 0 24px; +} + +.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; + flex: 1; + font: inherit; + line-height: 1.5; + outline: none; + padding: 6px 0; + resize: none; +} + +.SendButton { + background: var(--accent); + border: 0; + border-radius: 50%; + color: var(--accent-text); + flex: none; + font-size: 16px; + height: 36px; + width: 36px; +} + +.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..7281234f1 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -1,49 +1,163 @@ -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 +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 }, + { onSuccess: ({ chatId }) => setActiveChatId(chatId) }, + ) + } + + const composer = ( + + ) + + return ( +
+ + + ) } -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.', -] - -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), -) +function ChatPane({ chatId }: { chatId: string }) { + const query = useMessages(chatId) -function App() { + 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], @@ -55,82 +169,21 @@ function App() { directDomUpdates: true, }) - 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) - }, []) - React.useLayoutEffect(() => { if (didInitialScroll) return virtualizer.scrollToEnd() setDidInitialScroll(true) }, [didInitialScroll, virtualizer]) + // Sending a message jumps to it, like in most chat apps. Assistant output + // follows only when the reader is already at the latest message. + const lastMessage = messages[messages.length - 1] + const lastUserMessageId = + lastMessage?.author === 'user' ? lastMessage.id : null + React.useEffect(() => { + if (lastUserMessageId) virtualizer.scrollToEnd() + }, [lastUserMessageId, virtualizer]) + React.useEffect(() => { const id = window.setTimeout(() => { setAutoHistoryEnabled(true) @@ -139,93 +192,189 @@ function App() { return () => window.clearTimeout(id) }, []) - React.useEffect(() => { - return () => { - if (streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - } - } - }, []) - return ( -
-
-
- - - - -
-
- {loadingHistory - ? 'Loading history' - : virtualizer.isAtEnd(80) - ? 'At latest' - : 'Reading history'} -
-
- -
+
+
{ + const isAtEnd = virtualizer.isAtEnd(80) + setAtEnd(isAtEnd) + if (!autoHistoryEnabled || isAtEnd) 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 < 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 ? ( + message.text.split(/(```[\s\S]*?(?:```|$))/).map((part, index) => + part.startsWith('```') ? ( +
+              {part.replace(/^```\w*\n?/, '').replace(/```$/, '')}
+            
+ ) : ( + part.trim() && ( +

+ {part.trim()} +

+ ) + ), + ) + ) : ( + + + + + + )} +
+ ) +} + +function Composer({ + disabled, + streaming, + onSend, + onStop, +}: { + disabled: boolean + streaming: boolean + onSend: (text: string) => void + onStop: () => void +}) { + const [text, setText] = React.useState('') + const textareaRef = React.useRef(null) + + React.useLayoutEffect(() => { + const textarea = textareaRef.current! + textarea.style.height = 'auto' + if (text) { + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` + } + }, [text]) + + const submit = () => { + const value = text.trim() + if (!value || disabled || streaming) return + onSend(value) + setText('') + } + + return ( +
{ + event.preventDefault() + submit() + }} + > +