Skip to content

Commit b7d5ba9

Browse files
grichaclaude
andcommitted
Add virtualized rendering for chat messages
Implement @tanstack/react-virtual for efficient rendering of long chat sessions. Previously, opening a session with 1000+ messages would freeze the browser. Now only visible messages are rendered. - Add @tanstack/react-virtual dependency - Use useVirtualizer hook with dynamic height measurement - Implement smart auto-scroll that only scrolls when user is near bottom - Handle scroll position tracking to preserve scroll during loading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a7721e9 commit b7d5ba9

4 files changed

Lines changed: 71 additions & 24 deletions

File tree

TODO.md

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,7 @@
1616

1717
## Tasks
1818

19-
### Performance
20-
21-
#### Virtualize long chat session rendering
22-
**File**: `web/src/pages/WorkspaceDetail.tsx` (or wherever chat messages are rendered)
23-
24-
Opening a long chat session (1000+ messages) freezes the browser because all messages are rendered at once. Need to implement virtualized list rendering.
25-
26-
**Fix**:
27-
1. Only render last ~100 messages initially
28-
2. Use virtualization library (react-window or @tanstack/virtual) to render only visible messages
29-
3. Load more messages on scroll up
30-
4. Unload messages when scrolling away to keep DOM size manageable
31-
5. Keep scroll position stable when loading older messages
19+
*No active tasks*
3220

3321
---
3422

web/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@radix-ui/react-slot": "^1.2.4",
2020
"@tailwindcss/typography": "^0.5.19",
2121
"@tanstack/react-query": "^5.90.16",
22+
"@tanstack/react-virtual": "^3.13.16",
2223
"class-variance-authority": "^0.7.1",
2324
"clsx": "^2.1.1",
2425
"ghostty-web": "^0.4.0",

web/src/components/Chat.tsx

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef, useState, useCallback } from 'react'
22
import { Send, StopCircle, Bot, Sparkles, Wrench, ChevronDown, CheckCircle2, Loader2, Code2 } from 'lucide-react'
33
import Markdown from 'react-markdown'
4+
import { useVirtualizer } from '@tanstack/react-virtual'
45
import { getChatUrl, api, type AgentType } from '@/lib/api'
56
import { Button } from '@/components/ui/button'
67
import { Textarea } from '@/components/ui/textarea'
@@ -314,11 +315,36 @@ export function Chat({ workspaceName, sessionId: initialSessionId, onSessionId,
314315
const turnIdRef = useRef(0)
315316

316317
const wsRef = useRef<WebSocket | null>(null)
317-
const messagesEndRef = useRef<HTMLDivElement>(null)
318+
const scrollContainerRef = useRef<HTMLDivElement>(null)
318319
const textareaRef = useRef<HTMLTextAreaElement>(null)
320+
const shouldAutoScrollRef = useRef(true)
321+
322+
const virtualizer = useVirtualizer({
323+
count: messages.length,
324+
getScrollElement: () => scrollContainerRef.current,
325+
estimateSize: () => 80,
326+
overscan: 5,
327+
getItemKey: (index) => `msg-${index}-${messages[index]?.turnId ?? index}`,
328+
})
319329

320330
const scrollToBottom = useCallback(() => {
321-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
331+
if (scrollContainerRef.current && shouldAutoScrollRef.current) {
332+
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight
333+
}
334+
}, [])
335+
336+
useEffect(() => {
337+
const container = scrollContainerRef.current
338+
if (!container) return
339+
340+
const handleScroll = () => {
341+
const { scrollTop, scrollHeight, clientHeight } = container
342+
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100
343+
shouldAutoScrollRef.current = isNearBottom
344+
}
345+
346+
container.addEventListener('scroll', handleScroll)
347+
return () => container.removeEventListener('scroll', handleScroll)
322348
}, [])
323349

324350
useEffect(() => {
@@ -550,16 +576,16 @@ export function Chat({ workspaceName, sessionId: initialSessionId, onSessionId,
550576
</div>
551577
</div>
552578

553-
<div className="flex-1 overflow-y-auto p-4 space-y-4">
579+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
554580
{isLoadingHistory && (
555-
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
581+
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-4">
556582
<Loader2 className="h-8 w-8 animate-spin mb-4" />
557583
<p className="text-center">Loading conversation history...</p>
558584
</div>
559585
)}
560586

561587
{!isLoadingHistory && messages.length === 0 && !isStreaming && (
562-
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
588+
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-4">
563589
<Sparkles className="h-12 w-12 mb-4 opacity-20" />
564590
<p className="text-center">
565591
Start a conversation with {agentType === 'opencode' ? 'OpenCode' : 'Claude Code'}
@@ -570,15 +596,42 @@ export function Chat({ workspaceName, sessionId: initialSessionId, onSessionId,
570596
</div>
571597
)}
572598

573-
{messages.map((msg, idx) => (
574-
<MessageBubble key={idx} message={msg} />
575-
))}
599+
{!isLoadingHistory && messages.length > 0 && (
600+
<div
601+
style={{
602+
height: `${virtualizer.getTotalSize()}px`,
603+
width: '100%',
604+
position: 'relative',
605+
}}
606+
>
607+
{virtualizer.getVirtualItems().map((virtualRow) => {
608+
const message = messages[virtualRow.index]
609+
return (
610+
<div
611+
key={virtualRow.key}
612+
data-index={virtualRow.index}
613+
ref={virtualizer.measureElement}
614+
style={{
615+
position: 'absolute',
616+
top: 0,
617+
left: 0,
618+
width: '100%',
619+
transform: `translateY(${virtualRow.start}px)`,
620+
}}
621+
className="p-4 pb-0"
622+
>
623+
<MessageBubble message={message} />
624+
</div>
625+
)
626+
})}
627+
</div>
628+
)}
576629

577630
{isStreaming && (
578-
<StreamingMessage parts={streamingParts} />
631+
<div className="p-4 pt-0">
632+
<StreamingMessage parts={streamingParts} />
633+
</div>
579634
)}
580-
581-
<div ref={messagesEndRef} />
582635
</div>
583636

584637
<div className="border-t p-4 pb-4">

0 commit comments

Comments
 (0)