Skip to content

Commit bdaf32b

Browse files
piecykclaude
andauthored
docs(examples): rebuild the React chat example as a ChatGPT-style app (#1331)
* 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 <noreply@anthropic.com> * docs(examples): fix clipped sidebar rows and small icons in the chat example The chat list rows shrank below their text height once the list overflowed, which cut off the text and the selected-row highlight on phones. Keep them at their natural height, and replace the text-glyph icons with 20px SVG icons in 40px buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): jump to the end after a send in the chat example The empty assistant reply is added in the same render as the user message, so the last message was never the user's and scrollToEnd() never ran. Watch the latest user message instead, and skip the first render. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): hide the jump button when the chat opens at the end React's onScroll runs before the virtualizer reads the scroll event, so isAtEnd() there saw the previous position and the button stayed visible after the list landed at the end. Update the at-end state in the virtualizer's onChange instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): simplify the chat example - Seed 5 chats instead of 25, and generate replies from one sentence bank (no lists or code blocks), so the mock API and rendering are shorter. - Add the streamed reply with its first words, which removes the typing indicator and fixes an empty reply left behind by an early Stop. - Scroll to the end in one layout effect keyed on the latest user message, replacing the initial-scroll state, the seen-message ref and the history-load timer. - Let the composer grow with field-sizing: content instead of JavaScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): add prompt hints to the empty chat Show three prompts under the composer when no chat is open. A tap sends the prompt as the first message, so a new chat and a streamed reply start in one step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): link the chat blog post and add icons to the prompt hints - Answer "How do I virtualize a chat list?" with a fixed reply that links Tanner Linsley's "Chat UIs Are Lists Until They Aren't", and render links in assistant messages. - Add a colored Feather icon to each prompt hint. - Rename the seeded "Plan a weekend in Kraków" chat to Rzeszów. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): keep the chat opened while a send is in flight The send selects its chat when it finishes, about 100 ms later. Opening another chat in that time was undone. Select the sent-to chat only when the selection has not changed since the send started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(examples): load older messages after a direct jump to the top The scroll handler returned early when isAtEnd() was true. React's onScroll runs before the virtualizer reads the event, so after one jump from the end to the top it still saw the old position, skipped the load and did not record scrollTop. Drop the check: scrolling up near the top already gates the load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6789f7a commit bdaf32b

7 files changed

Lines changed: 1039 additions & 237 deletions

File tree

‎examples/react/chat/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ Demonstrates end-anchored virtualization for chat-style UIs:
77
- follows appended output only when the viewport is already at the latest message
88
- keeps streaming bottom output pinned as the last row grows
99

10+
The UI is a simple ChatGPT-style layout: a sidebar with chat history and a
11+
message pane with a composer. Data comes from an in-memory mock API
12+
(`src/api.ts`) through TanStack Query, and assistant replies are generated and
13+
streamed word by word, so the example needs no server or API key.
14+
1015
```bash
1116
npm install
1217
npm run dev

‎examples/react/chat/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"serve": "vite preview"
99
},
1010
"dependencies": {
11+
"@tanstack/react-query": "^5.80.7",
1112
"@tanstack/react-virtual": "^3.14.19",
1213
"react": "^19.2.7",
1314
"react-dom": "^19.2.7"

‎examples/react/chat/src/api.ts‎

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// An in-memory stand-in for a chat backend. Seeded chats are generated on
2+
// first read from a fixed seed, so the history is the same after a reload.
3+
4+
export type Author = 'user' | 'assistant'
5+
6+
export type Message = {
7+
id: string
8+
author: Author
9+
text: string
10+
}
11+
12+
export type Chat = {
13+
id: string
14+
title: string
15+
updatedAt: number
16+
}
17+
18+
export type MessagePage = {
19+
messages: Message[]
20+
// Index of the first message of this page in the full chat history.
21+
start: number
22+
}
23+
24+
const PAGE_SIZE = 30
25+
const READ_LATENCY = 300
26+
const WRITE_LATENCY = 50
27+
28+
type Random = () => number
29+
30+
function seededRandom(seed: number): Random {
31+
return () => {
32+
seed = (seed + 0x6d2b79f5) | 0
33+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
34+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
35+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
36+
}
37+
}
38+
39+
const pick = <T>(random: Random, items: ReadonlyArray<T>): T =>
40+
items[Math.floor(random() * items.length)]!
41+
42+
const between = (random: Random, min: number, max: number) =>
43+
min + Math.floor(random() * (max - min + 1))
44+
45+
const delay = (ms: number) =>
46+
new Promise<void>((resolve) => setTimeout(resolve, ms))
47+
48+
const titles = [
49+
'Debounce a search input',
50+
'Virtualize a 10k row table',
51+
'Explain React Suspense',
52+
'Plan a weekend in Rzeszów',
53+
'Beginner running plan',
54+
]
55+
56+
const followUps = [
57+
'Can you explain that in more detail?',
58+
'Show me an example.',
59+
'What are the trade-offs?',
60+
'How would I test this?',
61+
'Can you make it shorter?',
62+
'Why does that work?',
63+
'What if the list has 10,000 items?',
64+
'Is there a simpler way?',
65+
'What would you do differently in production?',
66+
'Ok, and what is the next step?',
67+
'Can you put that in a table or a list?',
68+
'Thanks! One more question about the last point.',
69+
]
70+
71+
const sentences = [
72+
'Start with the smallest version that works and measure it before you optimize.',
73+
'The main cost is usually not the code itself but the coordination around it.',
74+
'If the input changes often, keep the derived values out of state and compute them on render.',
75+
'A good default is to keep the data close to where it is used.',
76+
'You can always add a cache later, but removing one is much harder.',
77+
'Most of the time the simple solution is fast enough.',
78+
'Try to make the common case fast and the rare case correct.',
79+
'Write down the assumptions first, because they tend to change.',
80+
'This also makes the behavior easier to test in isolation.',
81+
'Watch out for edge cases at the boundaries, such as empty lists and very long values.',
82+
'Keep each step small enough that you can undo it without losing much.',
83+
'In practice, people tend to overestimate how much flexibility they need.',
84+
'The trade-off is a bit more setup in exchange for fewer surprises later.',
85+
'When in doubt, pick the option that is easier to change.',
86+
'It helps to look at a real example before you generalize.',
87+
'Consistency matters more than the exact choice.',
88+
]
89+
90+
// One to three paragraphs of random sentences, so rows have different heights.
91+
function generateReply(random: Random, prompt: string): string {
92+
const paragraphs = Array.from({ length: between(random, 1, 3) }, () =>
93+
Array.from({ length: between(random, 1, 4) }, () =>
94+
pick(random, sentences),
95+
).join(' '),
96+
)
97+
return [`You asked: “${prompt}”`, ...paragraphs].join('\n\n')
98+
}
99+
100+
// Fixed replies for prompts that deserve a real answer.
101+
const cannedReplies: Record<string, string> = {
102+
'How do I virtualize a chat list?': [
103+
'You asked: “How do I virtualize a chat list?”',
104+
'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.',
105+
"Tanner Linsley explains it in “Chat UIs Are Lists Until They Aren't”: https://tanstack.com/blog/tanstack-virtual-chat",
106+
].join('\n\n'),
107+
}
108+
109+
const now = Date.now()
110+
const chats: Chat[] = titles.map((title, index) => ({
111+
id: `chat-${index}`,
112+
title,
113+
updatedAt: now - index * 5 * 60 * 60 * 1000,
114+
}))
115+
const messagesByChat = new Map<string, Message[]>()
116+
let nextId = 0
117+
118+
function seedMessages(chatId: string): Message[] {
119+
const chatIndex = chats.findIndex((chat) => chat.id === chatId)
120+
const random = seededRandom(chatIndex + 1)
121+
const title = chats[chatIndex]!.title
122+
const turns = between(random, 20, 150)
123+
const messages: Message[] = []
124+
for (let turn = 0; turn < turns; turn++) {
125+
const prompt =
126+
turn === 0 ? `${title}. Where should I start?` : pick(random, followUps)
127+
messages.push({ id: `${chatId}-${turn}-q`, author: 'user', text: prompt })
128+
messages.push({
129+
id: `${chatId}-${turn}-a`,
130+
author: 'assistant',
131+
text: generateReply(random, prompt),
132+
})
133+
}
134+
return messages
135+
}
136+
137+
function getMessages(chatId: string): Message[] {
138+
let messages = messagesByChat.get(chatId)
139+
if (!messages) {
140+
messages = seedMessages(chatId)
141+
messagesByChat.set(chatId, messages)
142+
}
143+
return messages
144+
}
145+
146+
function touchChat(chatId: string) {
147+
const chat = chats.find((item) => item.id === chatId)!
148+
chat.updatedAt = Date.now()
149+
}
150+
151+
export async function fetchChats(): Promise<Chat[]> {
152+
await delay(READ_LATENCY)
153+
return [...chats].sort((a, b) => b.updatedAt - a.updatedAt)
154+
}
155+
156+
// Returns the page of messages that ends right before `before`, or the
157+
// latest page when `before` is null.
158+
export async function fetchMessages(
159+
chatId: string,
160+
before: number | null,
161+
): Promise<MessagePage> {
162+
await delay(READ_LATENCY)
163+
const messages = getMessages(chatId)
164+
const end = before ?? messages.length
165+
const start = Math.max(0, end - PAGE_SIZE)
166+
return { messages: messages.slice(start, end), start }
167+
}
168+
169+
export async function createChat(firstMessage: string): Promise<Chat> {
170+
await delay(WRITE_LATENCY)
171+
const title =
172+
firstMessage.length > 40
173+
? `${firstMessage.slice(0, 40).trim()}…`
174+
: firstMessage
175+
const chat: Chat = { id: `chat-new-${nextId++}`, title, updatedAt: 0 }
176+
chats.push(chat)
177+
messagesByChat.set(chat.id, [])
178+
return chat
179+
}
180+
181+
export async function postMessage(
182+
chatId: string,
183+
text: string,
184+
): Promise<Message> {
185+
await delay(WRITE_LATENCY)
186+
const message: Message = { id: `m-${nextId++}`, author: 'user', text }
187+
getMessages(chatId).push(message)
188+
touchChat(chatId)
189+
return message
190+
}
191+
192+
// Streams a generated reply word by word. `onUpdate` gets the whole message
193+
// so far on every chunk. Returns a stop function that keeps the partial reply.
194+
export function streamReply(
195+
chatId: string,
196+
prompt: string,
197+
onUpdate: (message: Message) => void,
198+
onDone: () => void,
199+
): () => void {
200+
const messages = getMessages(chatId)
201+
const index = messages.length
202+
const reply = cannedReplies[prompt] ?? generateReply(Math.random, prompt)
203+
const words = reply.match(/\s*\S+/g) ?? []
204+
let message: Message = { id: `m-${nextId++}`, author: 'assistant', text: '' }
205+
let position = 0
206+
207+
const intervalId = setInterval(() => {
208+
const count = 1 + Math.floor(Math.random() * 3)
209+
message = {
210+
...message,
211+
text: message.text + words.slice(position, position + count).join(''),
212+
}
213+
position += count
214+
messages[index] = message
215+
onUpdate(message)
216+
if (position >= words.length) {
217+
clearInterval(intervalId)
218+
touchChat(chatId)
219+
onDone()
220+
}
221+
}, 40)
222+
223+
return () => clearInterval(intervalId)
224+
}

0 commit comments

Comments
 (0)