perf: memo chat items, tool viewers, CollapsibleTeamSection, TaskTooltip

Wraps BaseItem, StatusDot, MetricsPill, LinkedToolItem, TeammateMessageItem,
SlashItem, all linkedTool viewers (Default/Edit/Read/Skill/Write/ToolError,
CollapsibleOutputSection), CollapsibleTeamSection, and TaskTooltip in
React.memo to prevent unnecessary re-renders when chat history updates.
This commit is contained in:
Mike 2026-05-02 22:17:12 +05:00
parent 193def1d59
commit 5d6667b23d
14 changed files with 749 additions and 719 deletions

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { memo } from 'react';
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables'; import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors'; import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
@ -57,14 +57,14 @@ interface BaseItemProps {
/** /**
* Small status dot indicator. * Small status dot indicator.
*/ */
export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) {
return ( return (
<span <span
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full" className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: getStatusDotColor(status) }} style={{ backgroundColor: getStatusDotColor(status) }}
/> />
); );
}; });
// ============================================================================= // =============================================================================
// Main Component // Main Component
@ -79,7 +79,8 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
* *
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem * Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
*/ */
export const BaseItem: React.FC<BaseItemProps> = ({ export const BaseItem = memo(
({
icon, icon,
label, label,
summary, summary,
@ -97,7 +98,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
children, children,
}) => { }: BaseItemProps): React.JSX.Element => {
return ( return (
<div <div
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
@ -140,7 +141,10 @@ export const BaseItem: React.FC<BaseItemProps> = ({
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}> <span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
- -
</span> </span>
<span className="flex-1 truncate text-sm" style={{ color: 'var(--tool-item-summary)' }}> <span
className="flex-1 truncate text-sm"
style={{ color: 'var(--tool-item-summary)' }}
>
{summary} {summary}
</span> </span>
</> </>
@ -210,4 +214,5 @@ export const BaseItem: React.FC<BaseItemProps> = ({
)} )}
</div> </div>
); );
}; }
);

View file

@ -6,7 +6,7 @@
* for summary generation and token calculation. * for summary generation and token calculation.
*/ */
import React, { useRef } from 'react'; import React, { memo, useRef } from 'react';
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
@ -64,7 +64,8 @@ interface LinkedToolItemProps {
titleText?: string; titleText?: string;
} }
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({ export const LinkedToolItem = memo(
({
linkedTool, linkedTool,
onClick, onClick,
isExpanded, isExpanded,
@ -76,7 +77,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
notificationDotColor, notificationDotColor,
registerRef, registerRef,
titleText, titleText,
}) => { }: LinkedToolItemProps): React.JSX.Element => {
const status = getToolStatus(linkedTool); const status = getToolStatus(linkedTool);
const { isLight } = useTheme(); const { isLight } = useTheme();
const summary = getToolSummary(linkedTool.name, linkedTool.input); const summary = getToolSummary(linkedTool.name, linkedTool.input);
@ -233,4 +234,5 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
</BaseItem> </BaseItem>
</div> </div>
); );
}; }
);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
@ -41,7 +41,8 @@ interface MetricsPillProps {
// Unified Metrics Pill - Compact monospace pill with tooltip // Unified Metrics Pill - Compact monospace pill with tooltip
// ============================================================================= // =============================================================================
export const MetricsPill = ({ export const MetricsPill = memo(
({
mainSessionImpact, mainSessionImpact,
lastUsage, lastUsage,
isolatedLabel, isolatedLabel,
@ -212,4 +213,5 @@ export const MetricsPill = ({
)} )}
</> </>
); );
}; }
);

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { memo } from 'react';
import { Slash } from 'lucide-react'; import { Slash } from 'lucide-react';
@ -34,7 +34,8 @@ interface SlashItemProps {
* - MCP commands * - MCP commands
* - User-defined commands * - User-defined commands
*/ */
export const SlashItem: React.FC<SlashItemProps> = ({ export const SlashItem = memo(
({
slash, slash,
onClick, onClick,
isExpanded, isExpanded,
@ -44,7 +45,7 @@ export const SlashItem: React.FC<SlashItemProps> = ({
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
titleText, titleText,
}) => { }: SlashItemProps): React.JSX.Element => {
const hasInstructions = !!slash.instructions; const hasInstructions = !!slash.instructions;
// Display args or message as the description // Display args or message as the description
@ -78,4 +79,5 @@ export const SlashItem: React.FC<SlashItemProps> = ({
)} )}
</BaseItem> </BaseItem>
); );
}; }
);

View file

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React, { memo, useMemo } from 'react';
import { import {
CARD_BG, CARD_BG,
@ -75,14 +75,15 @@ function isResendMessage(message: TeammateMessage): boolean {
* *
* Operational noise (idle/shutdown/terminated) renders as minimal inline text. * Operational noise (idle/shutdown/terminated) renders as minimal inline text.
*/ */
export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({ export const TeammateMessageItem = memo(
({
teammateMessage, teammateMessage,
onClick, onClick,
isExpanded, isExpanded,
onReplyHover, onReplyHover,
highlightClasses = '', highlightClasses = '',
highlightStyle, highlightStyle,
}) => { }: TeammateMessageItemProps): React.JSX.Element => {
const colors = getTeamColorSet(teammateMessage.color); const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -132,7 +133,10 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
if (noiseLabel) { if (noiseLabel) {
return ( return (
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}> <div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} /> <span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: colors.border }}
/>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{teammateMessage.teammateId} {teammateMessage.teammateId}
</span> </span>
@ -258,4 +262,5 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
)} )}
</div> </div>
); );
}; }
);

View file

@ -5,7 +5,7 @@
* Shows a clickable header with label, StatusDot, and chevron toggle. * Shows a clickable header with label, StatusDot, and chevron toggle.
*/ */
import React, { useState } from 'react'; import React, { memo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
@ -18,11 +18,8 @@ interface CollapsibleOutputSectionProps {
label?: string; label?: string;
} }
export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({ export const CollapsibleOutputSection = memo(
status, ({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => {
children,
label = 'Output',
}) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
return ( return (
@ -30,7 +27,13 @@ export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> =
<button <button
type="button" type="button"
className="mb-1 flex items-center gap-2 text-xs" className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }} style={{
color: 'var(--tool-item-muted)',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
onClick={() => setIsExpanded((prev) => !prev)} onClick={() => setIsExpanded((prev) => !prev)}
> >
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} {isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
@ -54,4 +57,5 @@ export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> =
)} )}
</div> </div>
); );
}; }
);

View file

@ -4,7 +4,7 @@
* Default rendering for tools that don't have specialized viewers. * Default rendering for tools that don't have specialized viewers.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { type ItemStatus } from '../BaseItem'; import { type ItemStatus } from '../BaseItem';
@ -23,7 +23,10 @@ interface DefaultToolViewerProps {
status: ItemStatus; status: ItemStatus;
} }
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => { export const DefaultToolViewer = memo(function DefaultToolViewer({
linkedTool,
status,
}: DefaultToolViewerProps) {
const displayOutputContent = linkedTool.result const displayOutputContent = linkedTool.result
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content) ? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
: null; : null;
@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
)} )}
</> </>
); );
}; });

View file

@ -4,7 +4,7 @@
* Renders the Edit tool with DiffViewer. * Renders the Edit tool with DiffViewer.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { DiffViewer } from '@renderer/components/chat/viewers'; import { DiffViewer } from '@renderer/components/chat/viewers';
@ -20,7 +20,10 @@ interface EditToolViewerProps {
status: ItemStatus; status: ItemStatus;
} }
export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, status }) => { export const EditToolViewer = memo(function EditToolViewer({
linkedTool,
status,
}: EditToolViewerProps) {
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined; const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
@ -71,4 +74,4 @@ export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, stat
)} )}
</div> </div>
); );
}; });

View file

@ -4,7 +4,7 @@
* Renders the Read tool result using CodeBlockViewer. * Renders the Read tool result using CodeBlockViewer.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
@ -14,7 +14,7 @@ interface ReadToolViewerProps {
linkedTool: LinkedToolItem; linkedTool: LinkedToolItem;
} }
export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) => { export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) {
const filePath = linkedTool.input.file_path as string; const filePath = linkedTool.input.file_path as string;
// Prefer enriched toolUseResult data // Prefer enriched toolUseResult data
@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
: undefined; : undefined;
const isMarkdownFile = /\.mdx?$/i.test(filePath); const isMarkdownFile = /\.mdx?$/i.test(filePath);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(
isMarkdownFile ? 'preview' : 'code'
);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
)} )}
</div> </div>
); );
}; });

View file

@ -4,7 +4,7 @@
* Renders the Skill tool with its instructions in a code block viewer style. * Renders the Skill tool with its instructions in a code block viewer style.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { CodeBlockViewer } from '@renderer/components/chat/viewers'; import { CodeBlockViewer } from '@renderer/components/chat/viewers';
@ -14,7 +14,7 @@ interface SkillToolViewerProps {
linkedTool: LinkedToolItem; linkedTool: LinkedToolItem;
} }
export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool }) => { export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) {
const skillInstructions = linkedTool.skillInstructions; const skillInstructions = linkedTool.skillInstructions;
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill'; const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool })
)} )}
</div> </div>
); );
}; });

View file

@ -4,7 +4,7 @@
* Displays error output for tool results. * Displays error output for tool results.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { StatusDot } from '../BaseItem'; import { StatusDot } from '../BaseItem';
@ -16,7 +16,9 @@ interface ToolErrorDisplayProps {
linkedTool: LinkedToolItem; linkedTool: LinkedToolItem;
} }
export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }) => { export const ToolErrorDisplay = memo(function ToolErrorDisplay({
linkedTool,
}: ToolErrorDisplayProps) {
if (!linkedTool.result?.isError) return null; if (!linkedTool.result?.isError) return null;
return ( return (
@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }
</div> </div>
</div> </div>
); );
}; });

View file

@ -4,7 +4,7 @@
* Renders the Write tool result. * Renders the Write tool result.
*/ */
import React from 'react'; import React, { memo } from 'react';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
@ -14,7 +14,7 @@ interface WriteToolViewerProps {
linkedTool: LinkedToolItem; linkedTool: LinkedToolItem;
} }
export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool }) => { export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: WriteToolViewerProps) {
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined; const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
const filePath = const filePath =
@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
)} )}
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const CollapsibleTeamSection = ({ export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
title, title,
icon, icon,
badge, badge,
@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({
headerSurfaceClassName, headerSurfaceClassName,
keepMounted, keepMounted,
children, children,
}: CollapsibleTeamSectionProps): React.JSX.Element => { }: CollapsibleTeamSectionProps): React.JSX.Element {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const isOpen = forceOpen ? true : open; const isOpen = forceOpen ? true : open;
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({
)} )}
</section> </section>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { memo, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
@ -65,12 +65,12 @@ interface TaskTooltipProps {
* Tooltip that shows task summary on hover over any #taskId link. * Tooltip that shows task summary on hover over any #taskId link.
* Reads task data from the current team in the store. * Reads task data from the current team in the store.
*/ */
export const TaskTooltip = ({ export const TaskTooltip = memo(function TaskTooltip({
taskId, taskId,
teamName, teamName,
children, children,
side = 'top', side = 'top',
}: TaskTooltipProps): React.JSX.Element => { }: TaskTooltipProps): React.JSX.Element {
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
useStore( useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -194,4 +194,4 @@ export const TaskTooltip = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
}; });