From 3db5a3cc0b2c27bb0d0422aaa95e28c637194f87 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 7 Jun 2026 01:47:59 +0300 Subject: [PATCH] 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 --- .../ui/src/components/chat/ChatMessage.tsx | 5 +++ .../ui/src/components/chat/MessageList.tsx | 43 ++++++++++++++++++- .../components/chat/message/MessageBody.tsx | 26 ++++------- packages/ui/src/lib/reviewFlow.ts | 4 +- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5fc0563d..ce369ee8 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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 = ({ @@ -147,6 +149,7 @@ const ChatMessage: React.FC = ({ activeStreamingPhase = null, animateUserOnMount = false, onUserAnimationConsumed, + reviewTransferDirection = null, }) => { const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const alwaysShowMessageActions = isMobile || isTablet; @@ -1138,6 +1141,7 @@ const ChatMessage: React.FC = ({ turnGroupingContext={turnGroupingContext} errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} + reviewTransferDirection={reviewTransferDirection} /> @@ -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 diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 58c95315..cc3d58fa 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -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(({ @@ -459,6 +463,7 @@ const MessageRow = React.memo(({ onContentChange, animationHandlers, scrollToBottom, + reviewTransferDirection, }) => { return ( (({ assistantHeaderMessageId={assistantHeaderMessageId} isInActiveTurn={isInActiveTurn} activeStreamingPhase={activeStreamingPhase} + reviewTransferDirection={reviewTransferDirection} /> ); }, (prev, next) => { @@ -491,6 +497,7 @@ const MessageRow = React.memo(({ && 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 ( ); }); @@ -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 ( ); - }, [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 ( ); }; @@ -1118,6 +1141,20 @@ const MessageList = React.forwardRef(({ 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>(() => new Map()); const userAnimationRef = React.useRef<{ sessionKey: string | undefined; @@ -1692,6 +1729,7 @@ const MessageList = React.forwardRef(({ shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} activeStreamingPhase={activeStreamingPhase} + reviewTransferDirection={reviewTransferDirection} /> {trailingStreamingEntry ? ( (({ onUserAnimationConsumed={onUserAnimationConsumed} activeStreamingMessageId={activeStreamingMessageId} activeStreamingPhase={activeStreamingPhase} + reviewTransferDirection={reviewTransferDirection} /> ) : null} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 1eeb8659..f8eeb978 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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) => { 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); diff --git a/packages/ui/src/lib/reviewFlow.ts b/packages/ui/src/lib/reviewFlow.ts index de51f47e..b69cca29 100644 --- a/packages/ui/src/lib/reviewFlow.ts +++ b/packages/ui/src/lib/reviewFlow.ts @@ -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;