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
This commit is contained in:
Bohdan Triapitsyn
2026-04-06 00:43:19 +03:00
parent 4a3b7ec5c9
commit 4db1e6baad
5 changed files with 365 additions and 66 deletions
@@ -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<ChatMessageProps> = ({
};
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
@@ -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<MessageRowProps>(({
&& 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
@@ -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<string, React.CSSProperties>;
isMobile: boolean;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean;
animateRows: boolean;
}
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
activity,
isExpanded,
syntaxTheme,
isMobile,
onToggleTool,
onShowPopup,
onContentChange,
animateTailText,
animateRows,
}) => {
const handleToggle = React.useCallback(() => {
onToggleTool(activity.id);
}, [activity.id, onToggleTool]);
const content = (
<ToolPart
part={activity.part as ToolPartType}
isExpanded={isExpanded}
onToggle={handleToggle}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
};
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<StaticGroupedToolRowProps> = ({
toolName,
activities,
animateTailText,
animateRows,
}) => {
const content = (
<StaticToolRow
toolName={toolName}
activities={activities}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
};
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<ProgressiveGroupProps> = ({
return <FadeInOnReveal key={key}>{content}</FadeInOnReveal>;
};
const renderToolRow = (key: string, content: React.ReactNode, animate: boolean) => {
if (!animate) {
return wrapRow(key, content);
}
return wrapRow(
key,
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
);
};
const renderedRows = shouldRenderRows
? visibleRows.map((row, index) => {
switch (row.type) {
@@ -721,52 +854,46 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
);
case 'tool-expandable':
return renderToolRow(
row.activity.id,
<>
<ToolPart
part={row.activity.part as ToolPartType}
isExpanded={expandedTools.has(row.activity.id)}
onToggle={() => 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 (
<MemoExpandableToolRow
key={row.activity.id}
activity={row.activity}
isExpanded={expandedTools.has(row.activity.id)}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
case 'tool-static-group':
return renderToolRow(
`static-${row.toolName}-${row.activities[0]?.id ?? index}`,
<>
<StaticToolRow
toolName={row.toolName}
activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
/>
</>,
row.activities.some((activity) => animatedToolIds?.has(activity.id))
return (
<MemoStaticGroupedToolRow
key={`static-${row.toolName}-${row.activities[0]?.id ?? index}`}
toolName={row.toolName}
activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/>
);
case 'tool-fallback':
return renderToolRow(
row.activity.id,
<>
<ToolPart
part={row.activity.part as ToolPartType}
isExpanded={expandedTools.has(row.activity.id)}
onToggle={() => 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 (
<MemoExpandableToolRow
key={row.activity.id}
activity={row.activity}
isExpanded={expandedTools.has(row.activity.id)}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
default:
@@ -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<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -2472,4 +2473,12 @@ const ToolPart: React.FC<ToolPartProps> = ({
);
};
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;
});
@@ -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;
};