perf: avoid per-message review metadata checks

Compute review transfer state once per chat render
Hide transfer actions when linked review sessions are inactive
Remove session-list scans from individual message rows
This commit is contained in:
Bohdan Triapitsyn
2026-06-07 01:47:59 +03:00
parent 2e77376ca0
commit 3db5a3cc0b
4 changed files with 57 additions and 21 deletions
@@ -31,6 +31,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
@@ -133,6 +134,7 @@ interface ChatMessageProps {
activeStreamingPhase?: StreamPhase | null;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const ChatMessage: React.FC<ChatMessageProps> = ({
@@ -147,6 +149,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
activeStreamingPhase = null,
animateUserOnMount = false,
onUserAnimationConsumed,
reviewTransferDirection = null,
}) => {
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
const alwaysShowMessageActions = isMobile || isTablet;
@@ -1138,6 +1141,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
turnGroupingContext={turnGroupingContext}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
reviewTransferDirection={reviewTransferDirection}
/>
</div>
@@ -1171,6 +1175,7 @@ export default React.memo(ChatMessage, (prev, next) => {
)
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.reviewTransferDirection === next.reviewTransferDirection
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
@@ -16,6 +16,9 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const MESSAGE_LIST_OVERSCAN = 6;
@@ -444,6 +447,7 @@ interface MessageRowProps {
onContentChange: (reason?: ContentChangeReason) => void;
animationHandlers: AnimationHandlers;
scrollToBottom?: () => void;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const MessageRow = React.memo<MessageRowProps>(({
@@ -459,6 +463,7 @@ const MessageRow = React.memo<MessageRowProps>(({
onContentChange,
animationHandlers,
scrollToBottom,
reviewTransferDirection,
}) => {
return (
<ChatMessage
@@ -474,6 +479,7 @@ const MessageRow = React.memo<MessageRowProps>(({
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={isInActiveTurn}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
);
}, (prev, next) => {
@@ -491,6 +497,7 @@ const MessageRow = React.memo<MessageRowProps>(({
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.reviewTransferDirection === next.reviewTransferDirection
&& prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk
&& prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete
&& prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate
@@ -518,6 +525,7 @@ interface TurnBlockProps {
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const TurnBlock = React.memo(({
@@ -536,6 +544,7 @@ const TurnBlock = React.memo(({
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}: TurnBlockProps) => {
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
const handleToggleTurnGroup = React.useCallback(() => {
@@ -756,6 +765,7 @@ const TurnBlock = React.memo(({
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={Boolean(streamingAssistantMessageId) && message.info.id === streamingAssistantMessageId}
activeStreamingPhase={message.info.id === streamingAssistantMessageId ? activeStreamingPhase : null}
reviewTransferDirection={reviewTransferDirection}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
@@ -782,6 +792,7 @@ const TurnBlock = React.memo(({
turnGroupingContextBase,
streamingAssistantMessageId,
activeStreamingPhase,
reviewTransferDirection,
visibleAssistantMessages,
visibleAssistantIds,
visibleActivitySegments,
@@ -820,6 +831,7 @@ interface UngroupedMessageRowProps {
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const UngroupedMessageRow = React.memo(({
@@ -833,6 +845,7 @@ const UngroupedMessageRow = React.memo(({
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}: UngroupedMessageRowProps) => {
return (
<MessageRow
@@ -846,6 +859,7 @@ const UngroupedMessageRow = React.memo(({
scrollToBottom={scrollToBottom}
isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId}
activeStreamingPhase={message.info.id === activeStreamingMessageId ? activeStreamingPhase : null}
reviewTransferDirection={reviewTransferDirection}
/>
);
});
@@ -867,6 +881,7 @@ interface MessageListEntryProps {
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => {
@@ -896,6 +911,7 @@ const MessageListEntry = React.memo(({
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}: MessageListEntryProps) => {
if (entry.kind === 'ungrouped') {
return (
@@ -910,6 +926,7 @@ const MessageListEntry = React.memo(({
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
);
}
@@ -927,6 +944,7 @@ const MessageListEntry = React.memo(({
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
@@ -956,9 +974,10 @@ type StaticHistoryListProps = {
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
};
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase }: StaticHistoryListProps) => {
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
@@ -977,9 +996,10 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, virtualRows,
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={null}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
const paddingTop = shouldVirtualize && virtualRows.length > 0
? virtualRows[0]?.start ?? 0
@@ -1060,6 +1080,7 @@ const StreamingTailContent: React.FC<{
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
}> = ({
entry,
onMessageContentChange,
@@ -1075,6 +1096,7 @@ const StreamingTailContent: React.FC<{
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}) => {
return (
<MessageListEntry
@@ -1092,6 +1114,7 @@ const StreamingTailContent: React.FC<{
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
);
};
@@ -1118,6 +1141,20 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
const defaultActivityExpanded = activityRenderMode === 'summary';
const reviewTransferDirection = useGlobalSessionsStore((state) => {
const currentSession = state.activeSessions.find((session) => session.id === sessionKey);
const direction = getReviewTransferDirection(currentSession);
if (!currentSession || !direction) return null;
const targetSessionId = direction === 'review-to-original'
? getOriginalSessionID(currentSession)
: getReviewSessionID(currentSession);
if (!targetSessionId) return null;
return state.activeSessions.some((session) => session.id === targetSessionId)
? direction
: null;
});
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{
sessionKey: string | undefined;
@@ -1692,6 +1729,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
{trailingStreamingEntry ? (
<StreamingTailContent
@@ -1709,6 +1747,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
) : null}
</div>
@@ -48,9 +48,8 @@ import { useI18n } from '@/lib/i18n';
import { extractLoopbackUrls } from '@/lib/url';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import {
getReviewTransferDirection,
type ReviewTransferDirection,
sendImplementationResponseToReviewer,
sendReviewFeedbackToOriginal,
} from '@/lib/reviewFlow';
@@ -366,6 +365,7 @@ interface MessageBodyProps {
errorVariant?: 'error' | 'info';
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
reviewTransferDirection?: ReviewTransferDirection | null;
}
const TOOL_REVEAL_CACHE_MAX = 200;
@@ -967,6 +967,7 @@ const AssistantMessageBody = React.memo(({
turnGroupingContext,
errorMessage,
errorVariant = 'error',
reviewTransferDirection = null,
}: Omit<MessageBodyProps, 'isUser'>) => {
const { t } = useI18n();
const chatSurfaceMode = useChatSurfaceMode();
@@ -1121,26 +1122,15 @@ const AssistantMessageBody = React.memo(({
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getDirectoryForSession = useSessionUIStore((state) => state.getDirectoryForSession);
const currentSession = useGlobalSessionsStore((state) => {
if (!sessionId) return null;
for (const candidate of state.activeSessions) {
if (candidate.id === sessionId) return candidate;
}
for (const candidate of state.archivedSessions) {
if (candidate.id === sessionId) return candidate;
}
return null;
});
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects);
const effectiveDirectory = useEffectiveDirectory();
const currentReviewTransferDirection = getReviewTransferDirection(currentSession);
const isReviewSessionView = currentReviewTransferDirection === 'review-to-original';
const reviewTransferDirection = (!isMobile && !isVSCode) ? currentReviewTransferDirection : null;
const isReviewSessionView = reviewTransferDirection === 'review-to-original';
const effectiveReviewTransferDirection = (!isMobile && !isVSCode) ? reviewTransferDirection : null;
const reviewTransferAction = React.useMemo(() => {
const transferText = assistantPlanText.trim();
if (!sessionId || !effectiveDirectory || !transferText || !reviewTransferDirection) return undefined;
if (reviewTransferDirection === 'review-to-original') {
if (!sessionId || !effectiveDirectory || !transferText || !effectiveReviewTransferDirection) return undefined;
if (effectiveReviewTransferDirection === 'review-to-original') {
return {
ariaLabel: t('chat.messageBody.actions.sendReviewFeedback'),
tooltip: t('chat.messageBody.actions.sendReviewFeedback'),
@@ -1164,7 +1154,7 @@ const AssistantMessageBody = React.memo(({
}
},
};
}, [assistantPlanText, effectiveDirectory, reviewTransferDirection, sessionId, t]);
}, [assistantPlanText, effectiveDirectory, effectiveReviewTransferDirection, sessionId, t]);
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
const [isForkDialogOpen, setIsForkDialogOpen] = React.useState(false);
+3 -1
View File
@@ -226,7 +226,9 @@ export const sendImplementationResponseToReviewer = async (originalSessionID: st
openReviewSessionPanel(directory, reviewSession);
};
export const getReviewTransferDirection = (session: Session | null | undefined): 'review-to-original' | 'original-to-review' | null => {
export type ReviewTransferDirection = 'review-to-original' | 'original-to-review';
export const getReviewTransferDirection = (session: Session | null | undefined): ReviewTransferDirection | null => {
if (isReviewSession(session)) return 'review-to-original';
if (getReviewSessionID(session)) return 'original-to-review';
return null;