From 4db1e6baad97adda8986378135ecffd51eb0c397 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Apr 2026 00:43:19 +0300 Subject: [PATCH] perf: reduce active-turn chat rerenders during streaming - Keep older assistant messages in the active turn stable while new content streams - Prevent existing tool rows from rerendering when new tool activity is appended - Tighten message and tool memo comparisons to isolate render work to changed rows --- .../ui/src/components/chat/ChatMessage.tsx | 6 +- .../ui/src/components/chat/MessageList.tsx | 12 +- .../chat/message/parts/ProgressiveGroup.tsx | 233 ++++++++++++++---- .../chat/message/parts/ToolPart.tsx | 11 +- .../components/chat/message/renderCompare.ts | 169 +++++++++++++ 5 files changed, 365 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 68678170..df9b1431 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -30,7 +30,7 @@ import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; -import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare'; +import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); @@ -1129,12 +1129,14 @@ const ChatMessage: React.FC = ({ }; export default React.memo(ChatMessage, (prev, next) => { + const prevRole = deriveMessageRole(prev.message.info); + return areRenderRelevantMessageInfoEqual(prev.message.info, next.message.info) && areRenderRelevantPartsEqual(prev.message.parts, next.message.parts) && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) && prev.onContentChange === next.onContentChange - && prev.turnGroupingContext === next.turnGroupingContext + && areRelevantTurnGroupingContextsEqual(prev.turnGroupingContext, next.turnGroupingContext, prev.message.info.id, prevRole.isUser) && prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.isInActiveTurn === next.isInActiveTurn && prev.activeStreamingPhase === next.activeStreamingPhase diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index da8d5c96..ca5f5c09 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -3,7 +3,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual'; import ChatMessage from './ChatMessage'; -import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; +import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import TurnItem from './components/TurnItem'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; @@ -480,15 +480,7 @@ const MessageRow = React.memo(({ && prev.onUserAnimationConsumed === next.onUserAnimationConsumed && prev.onContentChange === next.onContentChange && prev.scrollToBottom === next.scrollToBottom - && prevTurn?.turnId === nextTurn?.turnId - && prevTurn?.isFirstAssistantInTurn === nextTurn?.isFirstAssistantInTurn - && prevTurn?.isLastAssistantInTurn === nextTurn?.isLastAssistantInTurn - && prevTurn?.activityOwnerMessageId === nextTurn?.activityOwnerMessageId - && prevTurn?.isWorking === nextTurn?.isWorking - && prevTurn?.isGroupExpanded === nextTurn?.isGroupExpanded - && prevTurn?.toggleGroup === nextTurn?.toggleGroup - && prevTurn?.activityGroupSegments === nextTurn?.activityGroupSegments - && prevTurn?.activityParts === nextTurn?.activityParts + && areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user') && prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.isInActiveTurn === next.isInActiveTurn && prev.activeStreamingPhase === next.activeStreamingPhase diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index bd9e12a9..a0cac13f 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -20,6 +20,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; +import { areRenderRelevantPartsEqual } from '../renderCompare'; interface ProgressiveGroupProps { parts: TurnActivityPart[]; @@ -380,6 +381,115 @@ type AggregatedRow = | { type: 'justification'; activity: TurnActivityPart } | { type: 'tool-fallback'; activity: TurnActivityPart }; +interface ExpandableToolRowProps { + activity: TurnActivityPart; + isExpanded: boolean; + syntaxTheme: Record; + isMobile: boolean; + onToggleTool: (toolId: string) => void; + onShowPopup: (content: ToolPopupContent) => void; + onContentChange?: (reason?: ContentChangeReason) => void; + animateTailText: boolean; + animateRows: boolean; +} + +const ExpandableToolRow: React.FC = ({ + activity, + isExpanded, + syntaxTheme, + isMobile, + onToggleTool, + onShowPopup, + onContentChange, + animateTailText, + animateRows, +}) => { + const handleToggle = React.useCallback(() => { + onToggleTool(activity.id); + }, [activity.id, onToggleTool]); + + const content = ( + + ); + + const maybeWrapped = animateTailText ? ( + + {content} + + ) : content; + + if (!animateRows) { + return maybeWrapped; + } + + return {maybeWrapped}; +}; + +const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => { + return prev.isExpanded === next.isExpanded + && prev.syntaxTheme === next.syntaxTheme + && prev.isMobile === next.isMobile + && prev.onToggleTool === next.onToggleTool + && prev.onShowPopup === next.onShowPopup + && prev.onContentChange === next.onContentChange + && prev.animateTailText === next.animateTailText + && prev.animateRows === next.animateRows + && prev.activity.id === next.activity.id + && prev.activity.kind === next.activity.kind + && prev.activity.endedAt === next.activity.endedAt + && areRenderRelevantPartsEqual([prev.activity.part], [next.activity.part]); +}); + +interface StaticGroupedToolRowProps { + toolName: string; + activities: TurnActivityPart[]; + animateTailText: boolean; + animateRows: boolean; +} + +const StaticGroupedToolRow: React.FC = ({ + toolName, + activities, + animateTailText, + animateRows, +}) => { + const content = ( + + ); + + const maybeWrapped = animateTailText ? ( + + {content} + + ) : content; + + if (!animateRows) { + return maybeWrapped; + } + + return {maybeWrapped}; +}; + +const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => { + return prev.toolName === next.toolName + && prev.animateTailText === next.animateTailText + && prev.animateRows === next.animateRows + && areActivityListsEqual(prev.activities, next.activities); +}); + /** * Aggregate sorted activity parts into display rows. * Static tools are rendered as one row per call. @@ -440,7 +550,36 @@ const aggregateRows = (parts: TurnActivityPart[]): AggregatedRow[] => { * Render a static aggregated tool row. * Shows: [icon] DisplayName file1.tsx file2.tsx ... */ -export const StaticToolRow: React.FC<{ +const areActivityListsEqual = (left: TurnActivityPart[], right: TurnActivityPart[]): boolean => { + if (left === right) { + return true; + } + + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + const leftActivity = left[index]; + const rightActivity = right[index]; + + if (leftActivity.id !== rightActivity.id) { + return false; + } + + if (leftActivity.kind !== rightActivity.kind || leftActivity.endedAt !== rightActivity.endedAt) { + return false; + } + + if (!areRenderRelevantPartsEqual([leftActivity.part], [rightActivity.part])) { + return false; + } + } + + return true; +}; + +const StaticToolRowInner: React.FC<{ toolName: string; activities: TurnActivityPart[]; animateTailText: boolean; @@ -590,6 +729,12 @@ export const StaticToolRow: React.FC<{ ); }; +export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => { + return prev.toolName === next.toolName + && prev.animateTailText === next.animateTailText + && areActivityListsEqual(prev.activities, next.activities); +}); + /** * Inline reasoning text block — rendered as dimmed italic markdown. */ @@ -683,18 +828,6 @@ const ProgressiveGroup: React.FC = ({ return {content}; }; - const renderToolRow = (key: string, content: React.ReactNode, animate: boolean) => { - if (!animate) { - return wrapRow(key, content); - } - return wrapRow( - key, - - {content} - - ); - }; - const renderedRows = shouldRenderRows ? visibleRows.map((row, index) => { switch (row.type) { @@ -721,52 +854,46 @@ const ProgressiveGroup: React.FC = ({ ); case 'tool-expandable': - return renderToolRow( - row.activity.id, - <> - onToggleTool(row.activity.id)} - syntaxTheme={syntaxTheme} - isMobile={isMobile} - onContentChange={onContentChange} - onShowPopup={onShowPopup} - animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} - /> - , - Boolean(animatedToolIds?.has(row.activity.id)) + return ( + ); case 'tool-static-group': - return renderToolRow( - `static-${row.toolName}-${row.activities[0]?.id ?? index}`, - <> - animatedToolIds?.has(activity.id))} - /> - , - row.activities.some((activity) => animatedToolIds?.has(activity.id)) + return ( + animatedToolIds?.has(activity.id))} + animateRows={animateRows} + /> ); case 'tool-fallback': - return renderToolRow( - row.activity.id, - <> - onToggleTool(row.activity.id)} - syntaxTheme={syntaxTheme} - isMobile={isMobile} - onContentChange={onContentChange} - onShowPopup={onShowPopup} - animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} - /> - , - Boolean(animatedToolIds?.has(row.activity.id)) + return ( + ); default: diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 2fc120a1..fe29b71e 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -40,6 +40,7 @@ import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; import { useDurationTickerNow } from './useDurationTicker'; import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId'; +import { areRenderRelevantPartsEqual } from '../renderCompare'; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; @@ -2472,4 +2473,12 @@ const ToolPart: React.FC = ({ ); }; -export default ToolPart; +export default React.memo(ToolPart, (prev, next) => { + return areRenderRelevantPartsEqual([prev.part], [next.part]) + && prev.isExpanded === next.isExpanded + && prev.syntaxTheme === next.syntaxTheme + && prev.isMobile === next.isMobile + && prev.onContentChange === next.onContentChange + && prev.onShowPopup === next.onShowPopup + && prev.animateTailText === next.animateTailText; +}); diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts index b198c98c..d3cc71c3 100644 --- a/packages/ui/src/components/chat/message/renderCompare.ts +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -1,4 +1,5 @@ import type { Message, Part } from '@opencode-ai/sdk/v2'; +import type { TurnActivityGroup, TurnActivityRecord, TurnDiffStats, TurnGroupingContext } from '../lib/turns/types'; type MessageRecord = { info: Message; @@ -113,3 +114,171 @@ export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, rig } return areRenderRelevantMessagesEqual(left, right); }; + +const areTurnDiffStatsEqual = (left?: TurnDiffStats, right?: TurnDiffStats): boolean => { + if (!left || !right) { + return left === right; + } + + return left.additions === right.additions + && left.deletions === right.deletions + && left.files === right.files; +}; + +const areTurnActivityRecordsEqual = (left: TurnActivityRecord, right: TurnActivityRecord): boolean => { + return left.id === right.id + && left.messageId === right.messageId + && left.kind === right.kind + && left.partIndex === right.partIndex + && left.endedAt === right.endedAt + && areRenderRelevantPartsEqual([left.part], [right.part]); +}; + +const areRelevantActivityPartsEqual = ( + left: TurnActivityRecord[] | undefined, + right: TurnActivityRecord[] | undefined, + messageId: string, +): boolean => { + let leftIndex = 0; + let rightIndex = 0; + + while (true) { + while (leftIndex < (left?.length ?? 0) && left?.[leftIndex]?.messageId !== messageId) { + leftIndex += 1; + } + while (rightIndex < (right?.length ?? 0) && right?.[rightIndex]?.messageId !== messageId) { + rightIndex += 1; + } + + const leftRecord = left?.[leftIndex]; + const rightRecord = right?.[rightIndex]; + + if (!leftRecord || !rightRecord) { + return leftRecord === rightRecord; + } + + if (!areTurnActivityRecordsEqual(leftRecord, rightRecord)) { + return false; + } + + leftIndex += 1; + rightIndex += 1; + } +}; + +const areTurnActivityGroupsEqual = (left: TurnActivityGroup, right: TurnActivityGroup): boolean => { + if (left.id !== right.id || left.anchorMessageId !== right.anchorMessageId || left.afterToolPartId !== right.afterToolPartId) { + return false; + } + + if (left.parts.length !== right.parts.length) { + return false; + } + + for (let index = 0; index < left.parts.length; index += 1) { + if (!areTurnActivityRecordsEqual(left.parts[index], right.parts[index])) { + return false; + } + } + + return true; +}; + +const hasRelevantActivitySegments = (segments: TurnActivityGroup[] | undefined, messageId: string): boolean => { + return Boolean(segments?.some((segment) => segment.anchorMessageId === messageId)); +}; + +const areRelevantActivitySegmentsEqual = ( + left: TurnActivityGroup[] | undefined, + right: TurnActivityGroup[] | undefined, + messageId: string, +): boolean => { + let leftIndex = 0; + let rightIndex = 0; + + while (true) { + while (leftIndex < (left?.length ?? 0) && left?.[leftIndex]?.anchorMessageId !== messageId) { + leftIndex += 1; + } + while (rightIndex < (right?.length ?? 0) && right?.[rightIndex]?.anchorMessageId !== messageId) { + rightIndex += 1; + } + + const leftSegment = left?.[leftIndex]; + const rightSegment = right?.[rightIndex]; + + if (!leftSegment || !rightSegment) { + return leftSegment === rightSegment; + } + + if (!areTurnActivityGroupsEqual(leftSegment, rightSegment)) { + return false; + } + + leftIndex += 1; + rightIndex += 1; + } +}; + +export const areRelevantTurnGroupingContextsEqual = ( + left: TurnGroupingContext | undefined, + right: TurnGroupingContext | undefined, + messageId: string, + isUserMessage: boolean, +): boolean => { + if (left === right) { + return true; + } + + if (!left || !right) { + return left === right; + } + + if (isUserMessage) { + return true; + } + + if (left.turnId !== right.turnId) return false; + if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false; + if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false; + if (left.isWorking !== right.isWorking) return false; + if (left.hasTools !== right.hasTools) return false; + if (left.hasReasoning !== right.hasReasoning) return false; + if (left.userMessageCreatedAt !== right.userMessageCreatedAt) return false; + if (left.userMessageVariant !== right.userMessageVariant) return false; + + const headerRelevant = left.headerMessageId === messageId || right.headerMessageId === messageId; + if (headerRelevant && left.headerMessageId !== right.headerMessageId) { + return false; + } + + const ownerRelevant = left.activityOwnerMessageId === messageId || right.activityOwnerMessageId === messageId; + if (ownerRelevant && left.activityOwnerMessageId !== right.activityOwnerMessageId) { + return false; + } + + if (!areRelevantActivityPartsEqual(left.activityParts, right.activityParts, messageId)) { + return false; + } + + if (!areRelevantActivitySegmentsEqual(left.activityGroupSegments, right.activityGroupSegments, messageId)) { + return false; + } + + const segmentsRelevant = hasRelevantActivitySegments(left.activityGroupSegments, messageId) + || hasRelevantActivitySegments(right.activityGroupSegments, messageId); + + if ((ownerRelevant || segmentsRelevant) && left.isGroupExpanded !== right.isGroupExpanded) { + return false; + } + + if ((ownerRelevant || segmentsRelevant) && left.toggleGroup !== right.toggleGroup) { + return false; + } + + if ((ownerRelevant || segmentsRelevant) && !areTurnDiffStatsEqual(left.diffStats, right.diffStats)) { + return false; + } + + return true; +};