perf(renderer): memoize chat and sidebar list item components

Wrap SessionItem, SubagentItem, ExecutionTrace, TextItem, ThinkingItem,
and DisplayItemList in React.memo. These components render repeatedly in
virtualized lists and AI chat groups — memoizing them eliminates redundant
renders when their props have not changed, reducing CPU work in active
sessions with many messages or long session sidebars.
This commit is contained in:
Mike 2026-05-02 20:49:16 +05:00
parent f764af17d8
commit fa38b90f9c
6 changed files with 1385 additions and 1356 deletions

View file

@ -87,7 +87,8 @@ function truncateText(text: string, maxLength: number): string {
* *
* The list is completely flat with no nested toggles or hierarchies. * The list is completely flat with no nested toggles or hierarchies.
*/ */
export const DisplayItemList = ({ export const DisplayItemList = React.memo(
({
items, items,
onItemClick, onItemClick,
expandedItemIds, expandedItemIds,
@ -101,7 +102,7 @@ export const DisplayItemList = ({
previewMaxLength, previewMaxLength,
timestampFormat, timestampFormat,
showItemMetaTooltip = false, showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element => { }: Readonly<DisplayItemListProps>): React.JSX.Element => {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null); const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
@ -113,7 +114,10 @@ export const DisplayItemList = ({
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
if (!replyLinkToolId) return false; if (!replyLinkToolId) return false;
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) if (
item.type === 'teammate_message' &&
item.teammateMessage.replyToToolId === replyLinkToolId
)
return true; return true;
return false; return false;
}; };
@ -354,7 +358,10 @@ export const DisplayItemList = ({
/> />
<Layers size={14} /> <Layers size={14} />
</div> </div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}> <span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted Compacted
</span> </span>
{item.tokenDelta && ( {item.tokenDelta && (
@ -428,4 +435,5 @@ export const DisplayItemList = ({
})} })}
</div> </div>
); );
}; }
);

View file

@ -46,7 +46,8 @@ interface ExecutionTraceProps {
// Execution Trace Component // Execution Trace Component
// ============================================================================= // =============================================================================
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({ export const ExecutionTrace: React.FC<ExecutionTraceProps> = React.memo(
({
items, items,
aiGroupId: _aiGroupId, aiGroupId: _aiGroupId,
highlightToolUseId, highlightToolUseId,
@ -54,7 +55,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
notificationColorMap, notificationColorMap,
searchExpandedItemId, searchExpandedItemId,
registerToolRef, registerToolRef,
}): React.JSX.Element => { }): React.JSX.Element => {
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null); const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
// Use searchExpandedItemId if set, otherwise use manually expanded item // Use searchExpandedItemId if set, otherwise use manually expanded item
@ -217,7 +218,10 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
/> />
<Layers size={14} /> <Layers size={14} />
</div> </div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}> <span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted Compacted
</span> </span>
{item.tokenDelta && ( {item.tokenDelta && (
@ -276,4 +280,5 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
})} })}
</div> </div>
); );
}; }
);

View file

@ -67,7 +67,8 @@ interface SubagentItemProps {
// Main Component - Linear-style DevTools Card // Main Component - Linear-style DevTools Card
// ============================================================================= // =============================================================================
export const SubagentItem: React.FC<SubagentItemProps> = ({ export const SubagentItem: React.FC<SubagentItemProps> = React.memo(
({
step, step,
subagent, subagent,
onClick, onClick,
@ -77,7 +78,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
highlightColor, highlightColor,
notificationColorMap, notificationColorMap,
registerToolRef, registerToolRef,
}) => { }) => {
const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent';
const subagentType = subagent.subagentType ?? 'Task'; const subagentType = subagent.subagentType ?? 'Task';
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
@ -483,7 +484,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
{hasIsolated && ( {hasIsolated && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} /> <CircleDot
className="size-3"
style={{ color: 'rgba(56, 189, 248, 0.7)' }}
/>
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}> <span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
{subagent.team ? 'Context Window' : 'Subagent Context'} {subagent.team ? 'Context Window' : 'Subagent Context'}
</span> </span>
@ -500,7 +504,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
{/* Per-phase breakdown when multi-phase */} {/* Per-phase breakdown when multi-phase */}
{isMultiPhase && {isMultiPhase &&
phaseData.phases.map((phase) => ( phaseData.phases.map((phase) => (
<div key={phase.phaseNumber} className="flex items-center justify-between pl-5"> <div
key={phase.phaseNumber}
className="flex items-center justify-between pl-5"
>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
Phase {phase.phaseNumber} Phase {phase.phaseNumber}
</span> </span>
@ -589,4 +596,5 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
)} )}
</div> </div>
); );
}; }
);

View file

@ -31,7 +31,8 @@ interface TextItemProps {
titleText?: string; titleText?: string;
} }
export const TextItem: React.FC<TextItemProps> = ({ export const TextItem: React.FC<TextItemProps> = React.memo(
({
step, step,
preview, preview,
onClick, onClick,
@ -44,7 +45,7 @@ export const TextItem: React.FC<TextItemProps> = ({
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
titleText, titleText,
}) => { }) => {
const fullContent = step.content.outputText ?? preview; const fullContent = step.content.outputText ?? preview;
const summary = searchQueryOverride const summary = searchQueryOverride
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
@ -79,4 +80,5 @@ export const TextItem: React.FC<TextItemProps> = ({
/> />
</BaseItem> </BaseItem>
); );
}; }
);

View file

@ -31,7 +31,8 @@ interface ThinkingItemProps {
titleText?: string; titleText?: string;
} }
export const ThinkingItem: React.FC<ThinkingItemProps> = ({ export const ThinkingItem: React.FC<ThinkingItemProps> = React.memo(
({
step, step,
preview, preview,
onClick, onClick,
@ -44,7 +45,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
titleText, titleText,
}) => { }) => {
const fullContent = step.content.thinkingText ?? preview; const fullContent = step.content.thinkingText ?? preview;
const summary = searchQueryOverride const summary = searchQueryOverride
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
@ -79,4 +80,5 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
/> />
</BaseItem> </BaseItem>
); );
}; }
);

View file

@ -4,7 +4,7 @@
* Supports right-click context menu for pane management. * Supports right-click context menu for pane management.
*/ */
import { useCallback, useRef, useState } from 'react'; import { memo, useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@ -156,7 +156,8 @@ const SessionRuntimeBadge = ({
); );
}; };
export const SessionItem = ({ export const SessionItem = memo(
({
session, session,
isActive, isActive,
isPinned, isPinned,
@ -164,7 +165,7 @@ export const SessionItem = ({
multiSelectActive, multiSelectActive,
isSelected, isSelected,
onToggleSelect, onToggleSelect,
}: Readonly<SessionItemProps>): React.JSX.Element => { }: Readonly<SessionItemProps>): React.JSX.Element => {
const { const {
openTab, openTab,
activeProjectId, activeProjectId,
@ -345,7 +346,9 @@ export const SessionItem = ({
{session.messageCount} {session.messageCount}
</span> </span>
<span style={{ opacity: 0.5 }}>·</span> <span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span> <span className="tabular-nums">
{formatShortTime(new Date(session.createdAt))}
</span>
{session.model && ( {session.model && (
<> <>
<span style={{ opacity: 0.5 }}>·</span> <span style={{ opacity: 0.5 }}>·</span>
@ -390,4 +393,5 @@ export const SessionItem = ({
)} )}
</> </>
); );
}; }
);