Skip to content

Commit cc76c93

Browse files
committed
perf(chat): 聊天面板虚拟化列表优化长对话性能
- 引入 @tanstack/react-virtual 实现消息列表虚拟化 - 只渲染可视区域内的消息,大幅减少 DOM 节点数量 - 支持动态高度测量,适配不同长度的消息内容 - 任务状态和文件变更提示纳入虚拟化统一处理 Made-with: Cursor
1 parent 26c0586 commit cc76c93

File tree

3 files changed

+99
-29
lines changed

3 files changed

+99
-29
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@radix-ui/react-tooltip": "^1.2.8",
3636
"@tabler/icons-react": "^3.35.0",
3737
"@tailwindcss/vite": "^4.1.17",
38+
"@tanstack/react-virtual": "^3.13.21",
3839
"@types/react-syntax-highlighter": "^15.5.13",
3940
"@uiw/react-json-view": "2.0.0-alpha.40",
4041
"@xterm/addon-fit": "^0.10.0",

frontend/pnpm-lock.yaml

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

frontend/src/components/console/task/chat-panel.tsx

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
21
import { MessageItem, type MessageType } from "./message"
32
import React from "react"
3+
import { useVirtualizer } from "@tanstack/react-virtual"
44
import { Button } from "@/components/ui/button"
55
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
66
import { Label } from "@/components/ui/label"
@@ -73,12 +73,43 @@ export const TaskChatPanel = ({ messages, cli, streamStatus, disabled, thinkingM
7373
}
7474
}, [streamStatus])
7575

76+
const displayMessages = React.useMemo(
77+
() => messages.filter((message) => message.type !== 'agent_thought_chunk'),
78+
[messages]
79+
)
80+
81+
const virtualRows = React.useMemo(() => {
82+
const rows: Array<{ type: 'message'; message: MessageType } | { type: 'taskStatus' } | { type: 'fileChanges' }> = displayMessages.map((m) => ({ type: 'message' as const, message: m }))
83+
if (streamStatus !== 'waiting') {
84+
rows.push({ type: 'taskStatus' })
85+
}
86+
if (!disabled && fileChanges.length > 0 && showSubmitButton) {
87+
rows.push({ type: 'fileChanges' })
88+
}
89+
return rows
90+
}, [displayMessages, streamStatus, disabled, fileChanges.length, showSubmitButton])
91+
92+
const virtualizer = useVirtualizer({
93+
count: virtualRows.length,
94+
getScrollElement: () => scrollContainerRef.current,
95+
estimateSize: (index) => {
96+
const row = virtualRows[index]
97+
if (row.type === 'taskStatus') return 40
98+
if (row.type === 'fileChanges') return 50
99+
return 120
100+
},
101+
overscan: 5,
102+
gap: 4,
103+
})
104+
105+
const virtualItems = virtualizer.getVirtualItems()
106+
76107
// 自动滚动到底部
77108
React.useEffect(() => {
78-
if (scrollContainerRef.current) {
109+
if (scrollContainerRef.current && streamStatus !== 'waiting') {
79110
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight
80111
}
81-
}, [streamStatus])
112+
}, [streamStatus, virtualRows.length])
82113

83114
const renderTaskStatus = () => {
84115
if (streamStatus === 'inited') {
@@ -198,34 +229,52 @@ export const TaskChatPanel = ({ messages, cli, streamStatus, disabled, thinkingM
198229
</div>}
199230

200231
<div ref={scrollContainerRef} className="h-full overflow-y-auto p-2 border rounded-md">
201-
<div className="flex flex-col justify-end min-h-full gap-1">
202-
{messages.filter((message) => message.type !== 'agent_thought_chunk').map((message) => (
203-
<div key={message.id} id={`message-${message.id}`} className="scroll-mt-4">
204-
<MessageItem message={message as MessageType} cli={cli} />
205-
</div>
206-
))}
207-
{renderTaskStatus()}
208-
{!disabled && fileChanges.length > 0 && showSubmitButton ? (
209-
<div className="flex flex-row px-3 py-2 border rounded-md items-center bg-muted/50 mt-2">
210-
<div
211-
className="flex-1 text-xs cursor-pointer hover:text-primary transition-colors"
212-
onClick={() => setFileChangesDialogOpen(true)}
232+
<div
233+
style={{
234+
height: virtualizer.getTotalSize(),
235+
position: 'relative',
236+
}}
237+
>
238+
{virtualItems.map((virtualRow) => {
239+
const row = virtualRows[virtualRow.index]
240+
return (
241+
<div
242+
key={virtualRow.key}
243+
data-index={virtualRow.index}
244+
ref={virtualizer.measureElement}
245+
style={{
246+
position: 'absolute',
247+
top: 0,
248+
left: 0,
249+
width: '100%',
250+
transform: `translateY(${virtualRow.start}px)`,
251+
}}
213252
>
214-
{fileChanges.length} 个文件被修改,是否提交保存
253+
{row.type === 'message' && (
254+
<div id={`message-${row.message.id}`} className="scroll-mt-4">
255+
<MessageItem message={row.message as MessageType} cli={cli} />
256+
</div>
257+
)}
258+
{row.type === 'taskStatus' && renderTaskStatus()}
259+
{row.type === 'fileChanges' && (
260+
<div className="flex flex-row px-3 py-2 border rounded-md items-center bg-muted/50 mt-2">
261+
<div
262+
className="flex-1 text-xs cursor-pointer hover:text-primary transition-colors"
263+
onClick={() => setFileChangesDialogOpen(true)}
264+
>
265+
{fileChanges.length} 个文件被修改,是否提交保存
266+
</div>
267+
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setShowSubmitButton(false)}>
268+
不急
269+
</Button>
270+
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => sendUserInput("用 git 提交所有修改,并推送到远程仓库")}>
271+
提交
272+
</Button>
273+
</div>
274+
)}
215275
</div>
216-
217-
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
218-
setShowSubmitButton(false)
219-
}}>
220-
不急
221-
</Button>
222-
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
223-
sendUserInput("用 git 提交所有修改,并推送到远程仓库")
224-
}}>
225-
提交
226-
</Button>
227-
</div>
228-
) : null}
276+
)
277+
})}
229278
</div>
230279
</div>
231280
<FileChangesDialog

0 commit comments

Comments
 (0)