From 659c63e5bae47b8df1c081ed3e1dd8a916cf6046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 23 May 2026 23:41:25 +0800 Subject: [PATCH] perf(desktop): memoize chat row subtree to stop window-slide re-renders When the virtualization window slid during scroll, every visible row reconciled from scratch because the heavy chat-row subtree had no memo barriers, and because renderTranscriptItem rebuilt fresh branchAction / toolResult object literals each render that broke MessageBlock's existing memo. Wraps AssistantMessage, UserMessage, ToolCallBlock, ToolCallGroup, ToolResultBlock, and MarkdownRenderer in React.memo, and hoists the per-message branchAction and toolResult lookups into useMemo'd Maps keyed by message id. Window slides now keep referentially-stable props for unchanged rows, so reconciliation skips them and only newly entering rows pay full render cost. Tested: 722/722 desktop vitest suites pass Co-Authored-By: Claude Opus 4.7 --- .../src/components/chat/AssistantMessage.tsx | 5 ++- desktop/src/components/chat/MessageList.tsx | 44 ++++++++++++------- desktop/src/components/chat/ToolCallBlock.tsx | 6 +-- desktop/src/components/chat/ToolCallGroup.tsx | 6 +-- .../src/components/chat/ToolResultBlock.tsx | 6 +-- desktop/src/components/chat/UserMessage.tsx | 5 ++- .../components/markdown/MarkdownRenderer.tsx | 6 +-- 7 files changed, 47 insertions(+), 31 deletions(-) diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index bb38fd55..3151b5e1 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { MessageActionBar, type MessageBranchAction } from './MessageActionBar' import { InlineImageGallery } from './InlineImageGallery' @@ -8,7 +9,7 @@ type Props = { branchAction?: MessageBranchAction } -export function AssistantMessage({ content, isStreaming, branchAction }: Props) { +export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction }: Props) { if (!content.trim()) return null const documentLayout = shouldUseDocumentLayout(content) @@ -43,7 +44,7 @@ export function AssistantMessage({ content, isStreaming, branchAction }: Props) ) -} +}) function shouldUseDocumentLayout(content: string) { const normalized = content.trim() diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index be5ffc94..1b5a8b49 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1638,6 +1638,33 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { } }, [addToast, branchSession, branchingMessageId, resolvedSessionId, t]) + // Pre-compute per-message branchAction + toolResult lookups so MessageBlock's + // memo barrier is not broken by inline object literals on every render. + const branchActionByMessageId = useMemo(() => { + if (branchableMessageTargets.size === 0) { + return new Map void }>() + } + const result = new Map void }>() + const label = t('chat.branchFromHere') + for (const [uiMessageId, target] of branchableMessageTargets) { + result.set(uiMessageId, { + label, + loading: branchingMessageId === target.uiMessageId, + onBranch: () => { void handleBranchMessage(target) }, + }) + } + return result + }, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t]) + + const toolResultByToolUseId = useMemo(() => { + if (toolResultMap.size === 0) return new Map() + const result = new Map() + for (const [toolUseId, toolResult] of toolResultMap) { + result.set(toolUseId, { content: toolResult.content, isError: toolResult.isError }) + } + return result + }, [toolResultMap]) + const renderTranscriptItem = (item: RenderItem, index: number) => { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] @@ -1662,23 +1689,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { agentTaskNotifications={agentTaskNotifications} toolResult={ item.message.type === 'tool_use' - ? (() => { - const result = toolResultMap.get(item.message.toolUseId) - return result ? { content: result.content, isError: result.isError } : null - })() + ? toolResultByToolUseId.get(item.message.toolUseId) ?? null : null } - branchAction={ - (() => { - const branchTarget = branchableMessageTargets.get(item.message.id) - if (!branchTarget) return undefined - return { - label: t('chat.branchFromHere'), - loading: branchingMessageId === branchTarget.uiMessageId, - onBranch: () => { void handleBranchMessage(branchTarget) }, - } - })() - } + branchAction={branchActionByMessageId.get(item.message.id)} /> )} diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 2250b311..794fae62 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { LoaderCircle } from 'lucide-react' import { CodeViewer } from './CodeViewer' import { DiffViewer } from './DiffViewer' @@ -36,7 +36,7 @@ const TOOL_ICONS: Record = { const WRITER_PREVIEW_MAX_LINES = 120 const WRITER_PREVIEW_MAX_CHARS = 30000 -export function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) { +export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() const obj = input && typeof input === 'object' ? (input as Record) : {} @@ -122,7 +122,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false, isPend )} ) -} +}) function renderPreview( toolName: string, diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 97a9938b..4040376b 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react' import { ToolCallBlock } from './ToolCallBlock' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' @@ -108,7 +108,7 @@ function hasUnresolvedToolCalls( ) } -export function ToolCallGroup({ +export const ToolCallGroup = memo(function ToolCallGroup({ toolCalls, resultMap, childToolCallsByParent, @@ -159,7 +159,7 @@ export function ToolCallGroup({ isStreaming={isStreaming} /> ) -} +}) function ToolCallGroupContent({ toolCalls, diff --git a/desktop/src/components/chat/ToolResultBlock.tsx b/desktop/src/components/chat/ToolResultBlock.tsx index 791ae1b1..caa2d680 100644 --- a/desktop/src/components/chat/ToolResultBlock.tsx +++ b/desktop/src/components/chat/ToolResultBlock.tsx @@ -1,5 +1,5 @@ import { CodeViewer } from './CodeViewer' -import { useState } from 'react' +import { memo, useState } from 'react' import { useTranslation } from '../../i18n' import { InlineImageGallery } from './InlineImageGallery' @@ -15,7 +15,7 @@ type Props = { * inline within ToolCallBlock (i.e., when the tool_use and tool_result * are NOT grouped together by MessageList). */ -export function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) { +export const ToolResultBlock = memo(function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() @@ -90,7 +90,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true )} ) -} +}) function extractText(content: unknown): string { if (typeof content === 'string') return content diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 7773e560..026ee92d 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import type { UIAttachment } from '../../types/chat' import { AttachmentGallery } from './AttachmentGallery' import { MessageActionBar, type MessageBranchAction } from './MessageActionBar' @@ -8,7 +9,7 @@ type Props = { branchAction?: MessageBranchAction } -export function UserMessage({ content, attachments, branchAction }: Props) { +export const UserMessage = memo(function UserMessage({ content, attachments, branchAction }: Props) { const hasText = content.trim().length > 0 return ( @@ -45,4 +46,4 @@ export function UserMessage({ content, attachments, branchAction }: Props) { ) -} +}) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index cbda8982..419df4fe 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from 'react' +import { memo, useMemo, useCallback } from 'react' import type { MouseEvent as ReactMouseEvent } from 'react' import DOMPurify from 'dompurify' import katex from 'katex' @@ -396,7 +396,7 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className? .join(' ') } -export function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) { +export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) { const { html, codeBlocks, mathBlocks } = useMemo( () => cache ? getCachedMarkdownParse(content) : parseMarkdown(content), [cache, content], @@ -490,4 +490,4 @@ export function MarkdownRenderer({ content, variant = 'default', className, cach )} ) -} +})