From 5d6667b23da0fe36d5882c37a55d850a8036ca5a Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 22:17:12 +0500 Subject: [PATCH] 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. --- .../components/chat/items/BaseItem.tsx | 253 ++++++------- .../components/chat/items/LinkedToolItem.tsx | 336 +++++++++--------- .../components/chat/items/MetricsPill.tsx | 324 ++++++++--------- .../components/chat/items/SlashItem.tsx | 90 ++--- .../chat/items/TeammateMessageItem.tsx | 329 ++++++++--------- .../linkedTool/CollapsibleOutputSection.tsx | 72 ++-- .../items/linkedTool/DefaultToolViewer.tsx | 9 +- .../chat/items/linkedTool/EditToolViewer.tsx | 9 +- .../chat/items/linkedTool/ReadToolViewer.tsx | 10 +- .../chat/items/linkedTool/SkillToolViewer.tsx | 6 +- .../items/linkedTool/ToolErrorDisplay.tsx | 8 +- .../chat/items/linkedTool/WriteToolViewer.tsx | 6 +- .../team/CollapsibleTeamSection.tsx | 8 +- src/renderer/components/team/TaskTooltip.tsx | 8 +- 14 files changed, 749 insertions(+), 719 deletions(-) diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 375a832f..eb4795e3 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables'; import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors'; @@ -57,14 +57,14 @@ interface BaseItemProps { /** * Small status dot indicator. */ -export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { +export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) { return ( ); -}; +}); // ============================================================================= // Main Component @@ -79,135 +79,140 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { * * Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem */ -export const BaseItem: React.FC = ({ - icon, - label, - summary, - tokenCount, - tokenLabel = 'tokens', - status, - durationMs, - timestamp, - timestampFormat = 'HH:mm:ss', - titleText, - onClick, - isExpanded, - hasExpandableContent = true, - highlightClasses = '', - highlightStyle, - notificationDotColor, - children, -}) => { - return ( -
- {/* Clickable Header */} +export const BaseItem = memo( + ({ + icon, + label, + summary, + tokenCount, + tokenLabel = 'tokens', + status, + durationMs, + timestamp, + timestampFormat = 'HH:mm:ss', + titleText, + onClick, + isExpanded, + hasExpandableContent = true, + highlightClasses = '', + highlightStyle, + notificationDotColor, + children, + }: BaseItemProps): React.JSX.Element => { + return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5" - style={{ backgroundColor: 'transparent' }} - onMouseEnter={(e) => - Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' }) - } - onMouseLeave={(e) => - Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }) - } + className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} + style={highlightStyle} > - {/* Icon */} - - {icon} - + {/* Clickable Header */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5" + style={{ backgroundColor: 'transparent' }} + onMouseEnter={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' }) + } + onMouseLeave={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }) + } + > + {/* Icon */} + + {icon} + - {/* Label */} - - {label} - + {/* Label */} + + {label} + - {/* Separator and Summary */} - {summary && ( - <> - - - + {/* Separator and Summary */} + {summary && ( + <> + + - + + + {summary} + + + )} + + {/* Spacer if no summary */} + {!summary && } + + {/* Token count badge */} + {tokenCount != null && tokenCount > 0 && ( + + ~{formatTokens(tokenCount)} {tokenLabel} - - {summary} + )} + + {/* Status indicator - hidden when notification dot replaces it */} + {status && !notificationDotColor && } + + {/* Notification dot (replaces status dot when present) */} + {notificationDotColor && ( + + )} + + {/* Duration */} + {durationMs !== undefined && ( + + {formatDuration(durationMs)} - - )} + )} - {/* Spacer if no summary */} - {!summary && } + {/* Timestamp — rightmost info element */} + {timestamp && ( + + {format(timestamp, timestampFormat)} + + )} - {/* Token count badge */} - {tokenCount != null && tokenCount > 0 && ( - + )} +
+ + {/* Expanded Content */} + {isExpanded && children && ( +
- ~{formatTokens(tokenCount)} {tokenLabel} - - )} - - {/* Status indicator - hidden when notification dot replaces it */} - {status && !notificationDotColor && } - - {/* Notification dot (replaces status dot when present) */} - {notificationDotColor && ( - - )} - - {/* Duration */} - {durationMs !== undefined && ( - - {formatDuration(durationMs)} - - )} - - {/* Timestamp — rightmost info element */} - {timestamp && ( - - {format(timestamp, timestampFormat)} - - )} - - {/* Expand/collapse chevron */} - {hasExpandableContent && ( - + {children} +
)}
- - {/* Expanded Content */} - {isExpanded && children && ( -
- {children} -
- )} -
- ); -}; + ); + } +); diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 336ce076..1dac357c 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -6,7 +6,7 @@ * 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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; @@ -64,173 +64,175 @@ interface LinkedToolItemProps { titleText?: string; } -export const LinkedToolItem: React.FC = ({ - linkedTool, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - isHighlighted, - highlightColor, - notificationDotColor, - registerRef, - titleText, -}) => { - const status = getToolStatus(linkedTool); - const { isLight } = useTheme(); - const summary = getToolSummary(linkedTool.name, linkedTool.input); - const normalizedToolName = linkedTool.name.toLowerCase(); - const summaryNode = - searchQueryOverride && searchQueryOverride.trim().length > 0 - ? highlightQueryInText( - summary, - searchQueryOverride, - `${linkedTool.id ?? linkedTool.name}:summary`, - { - forceAllActive: true, - } - ) - : summary; - const elementRef = useRef(null); +export const LinkedToolItem = memo( + ({ + linkedTool, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + isHighlighted, + highlightColor, + notificationDotColor, + registerRef, + titleText, + }: LinkedToolItemProps): React.JSX.Element => { + const status = getToolStatus(linkedTool); + const { isLight } = useTheme(); + const summary = getToolSummary(linkedTool.name, linkedTool.input); + const normalizedToolName = linkedTool.name.toLowerCase(); + const summaryNode = + searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText( + summary, + searchQueryOverride, + `${linkedTool.id ?? linkedTool.name}:summary`, + { + forceAllActive: true, + } + ) + : summary; + const elementRef = useRef(null); - // Combined ref callback - handles both internal ref and external registration - const handleRef = (el: HTMLDivElement | null): void => { - // Update internal ref - (elementRef as React.MutableRefObject).current = el; - // Call external registration if provided - registerRef?.(el); - }; + // Combined ref callback - handles both internal ref and external registration + const handleRef = (el: HTMLDivElement | null): void => { + // Update internal ref + (elementRef as React.MutableRefObject).current = el; + // Call external registration if provided + registerRef?.(el); + }; - // Render teammate_spawned results as a minimal inline row - const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned'; - if (isTeammateSpawned) { - const teamResult = linkedTool.result!.toolUseResult!; - const name = (teamResult.name as string) || 'teammate'; - const color = (teamResult.color as string) || ''; - const colors = getTeamColorSet(color); - return ( -
- - - {name} - - - Teammate spawned - -
- ); - } - - // Render SendMessage shutdown_request as a minimal inline row - const isShutdownRequest = - linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request'; - if (isShutdownRequest) { - const target = (linkedTool.input?.recipient as string) || 'teammate'; - return ( -
- - - Shutdown requested →{' '} - {target} - -
- ); - } - - // Note: We no longer scroll locally - the navigation coordinator handles this - // via the registered ref. This prevents double-scroll issues. - - // Highlight animation for error deep linking (supports custom hex) - const effectiveColor = highlightColor ?? 'red'; - let highlightClasses = ''; - let highlightStyle: React.CSSProperties | undefined; - if (isHighlighted) { - if (isPresetColorKey(effectiveColor)) { - highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor]; - } else { - const hp = getToolHighlightProps(effectiveColor); - highlightClasses = hp.className; - highlightStyle = hp.style; - } - } - - // Determine which specialized viewer to use - const useReadViewer = - normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; - const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); - const useWriteViewer = - normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; - const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); - const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; - - // Check if we should show error display for Read/Write tools - const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; - const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; - - return ( -
- - } - label={linkedTool.name} - summary={summaryNode} - tokenCount={getToolContextTokens(linkedTool)} - status={status} - durationMs={linkedTool.durationMs} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - {/* Read tool with CodeBlockViewer */} - {useReadViewer && } - - {/* Edit tool with DiffViewer */} - {useEditViewer && } - - {/* Write tool */} - {useWriteViewer && } - - {/* Skill tool with instructions */} - {useSkillViewer && } - - {/* Default rendering for other tools */} - {useDefaultViewer && } - - {/* Error output for Read tool */} - {showReadError && } - - {/* Error output for Write tool */} - {showWriteError && } - - {/* Orphaned indicator */} - {linkedTool.isOrphaned && ( -
+ + - - No result received -
- )} - - {/* Timing */} -
- Duration: {formatDuration(linkedTool.durationMs)} + {name} + + + Teammate spawned +
-
-
- ); -}; + ); + } + + // Render SendMessage shutdown_request as a minimal inline row + const isShutdownRequest = + linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request'; + if (isShutdownRequest) { + const target = (linkedTool.input?.recipient as string) || 'teammate'; + return ( +
+ + + Shutdown requested →{' '} + {target} + +
+ ); + } + + // Note: We no longer scroll locally - the navigation coordinator handles this + // via the registered ref. This prevents double-scroll issues. + + // Highlight animation for error deep linking (supports custom hex) + const effectiveColor = highlightColor ?? 'red'; + let highlightClasses = ''; + let highlightStyle: React.CSSProperties | undefined; + if (isHighlighted) { + if (isPresetColorKey(effectiveColor)) { + highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor]; + } else { + const hp = getToolHighlightProps(effectiveColor); + highlightClasses = hp.className; + highlightStyle = hp.style; + } + } + + // Determine which specialized viewer to use + const useReadViewer = + normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; + const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); + const useWriteViewer = + normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; + const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); + const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; + + // Check if we should show error display for Read/Write tools + const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; + const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; + + return ( +
+ + } + label={linkedTool.name} + summary={summaryNode} + tokenCount={getToolContextTokens(linkedTool)} + status={status} + durationMs={linkedTool.durationMs} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {/* Read tool with CodeBlockViewer */} + {useReadViewer && } + + {/* Edit tool with DiffViewer */} + {useEditViewer && } + + {/* Write tool */} + {useWriteViewer && } + + {/* Skill tool with instructions */} + {useSkillViewer && } + + {/* Default rendering for other tools */} + {useDefaultViewer && } + + {/* Error output for Read tool */} + {showReadError && } + + {/* Error output for Write tool */} + {showWriteError && } + + {/* Orphaned indicator */} + {linkedTool.isOrphaned && ( +
+ + No result received +
+ )} + + {/* Timing */} +
+ Duration: {formatDuration(linkedTool.durationMs)} +
+
+
+ ); + } +); diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx index 22c083aa..9f276c45 100644 --- a/src/renderer/components/chat/items/MetricsPill.tsx +++ b/src/renderer/components/chat/items/MetricsPill.tsx @@ -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 { @@ -41,175 +41,177 @@ interface MetricsPillProps { // Unified Metrics Pill - Compact monospace pill with tooltip // ============================================================================= -export const MetricsPill = ({ - mainSessionImpact, - lastUsage, - isolatedLabel, - isolatedOverride, - phaseBreakdown, -}: Readonly): React.ReactElement | null => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipStyle, setTooltipStyle] = useState({}); - const containerRef = useRef(null); - const hideTimeoutRef = useRef | null>(null); +export const MetricsPill = memo( + ({ + mainSessionImpact, + lastUsage, + isolatedLabel, + isolatedOverride, + phaseBreakdown, + }: Readonly): React.ReactElement | null => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState({}); + const containerRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); - const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; - const hasIsolated = - isolatedOverride != null - ? isolatedOverride > 0 - : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; + const hasIsolated = + isolatedOverride != null + ? isolatedOverride > 0 + : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isolatedTotal = - isolatedOverride ?? - (lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0); + const isolatedTotal = + isolatedOverride ?? + (lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0); - const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; + const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; - const clearHideTimeout = (): void => { - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - }; - - const handleMouseEnter = (): void => { - clearHideTimeout(); - setShowTooltip(true); - }; - - const handleMouseLeave = (): void => { - clearHideTimeout(); - hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100); - }; - - useEffect(() => { - if (showTooltip && containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - const tooltipWidth = 220; - let left = rect.left + rect.width / 2 - tooltipWidth / 2; - if (left < 8) left = 8; - if (left + tooltipWidth > window.innerWidth - 8) { - left = window.innerWidth - tooltipWidth - 8; + const clearHideTimeout = (): void => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; } - setTooltipStyle({ - position: 'fixed', - bottom: window.innerHeight - rect.top + 6, - left, - width: tooltipWidth, - zIndex: 99999, - }); + }; + + const handleMouseEnter = (): void => { + clearHideTimeout(); + setShowTooltip(true); + }; + + const handleMouseLeave = (): void => { + clearHideTimeout(); + hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100); + }; + + useEffect(() => { + if (showTooltip && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const tooltipWidth = 220; + let left = rect.left + rect.width / 2 - tooltipWidth / 2; + if (left < 8) left = 8; + if (left + tooltipWidth > window.innerWidth - 8) { + left = window.innerWidth - tooltipWidth - 8; + } + setTooltipStyle({ + position: 'fixed', + bottom: window.innerHeight - rect.top + 6, + left, + width: tooltipWidth, + zIndex: 99999, + }); + } + }, [showTooltip]); + + useEffect(() => { + if (!showTooltip) return; + const handleScroll = (): void => setShowTooltip(false); + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [showTooltip]); + + useEffect(() => { + return () => clearHideTimeout(); + }, []); + + if (!hasMainImpact && !hasIsolated) { + return null; } - }, [showTooltip]); - useEffect(() => { - if (!showTooltip) return; - const handleScroll = (): void => setShowTooltip(false); - window.addEventListener('scroll', handleScroll, true); - return () => window.removeEventListener('scroll', handleScroll, true); - }, [showTooltip]); + const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; + const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; + const rightLabel = isolatedLabel ?? 'Subagent Context'; - useEffect(() => { - return () => clearHideTimeout(); - }, []); + return ( + <> +
+ {mainValue && {mainValue}} + {mainValue && isolatedValue && |} + {isolatedValue && {isolatedValue}} +
- if (!hasMainImpact && !hasIsolated) { - return null; - } - - const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; - const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; - const rightLabel = isolatedLabel ?? 'Subagent Context'; - - return ( - <> -
- {mainValue && {mainValue}} - {mainValue && isolatedValue && |} - {isolatedValue && {isolatedValue}} -
- - {showTooltip && - createPortal( -
-
- {hasMainImpact && ( -
- Main Context - - {mainSessionImpact.totalTokens.toLocaleString()} - -
- )} - {hasIsolated && ( -
- {rightLabel} - - {isolatedTotal.toLocaleString()} - -
- )} - {hasPhases && - phaseBreakdown.map((phase) => ( -
- - Phase {phase.phaseNumber} - - - {formatTokensCompact(phase.peakTokens)} - {phase.postCompaction != null && ( - - {' '} - → {formatTokensCompact(phase.postCompaction)} - - )} + {showTooltip && + createPortal( +
+
+ {hasMainImpact && ( +
+ Main Context + + {mainSessionImpact.totalTokens.toLocaleString()}
- ))} -
- {hasMainImpact && hasIsolated - ? 'Left: parent injection · Right: internal' - : hasMainImpact - ? 'Tokens injected to parent' - : 'Internal token usage'} + )} + {hasIsolated && ( +
+ {rightLabel} + + {isolatedTotal.toLocaleString()} + +
+ )} + {hasPhases && + phaseBreakdown.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} +
+ {hasMainImpact && hasIsolated + ? 'Left: parent injection · Right: internal' + : hasMainImpact + ? 'Tokens injected to parent' + : 'Internal token usage'} +
-
-
, - document.body - )} - - ); -}; +
, + document.body + )} + + ); + } +); diff --git a/src/renderer/components/chat/items/SlashItem.tsx b/src/renderer/components/chat/items/SlashItem.tsx index c48ece0b..df24caf6 100644 --- a/src/renderer/components/chat/items/SlashItem.tsx +++ b/src/renderer/components/chat/items/SlashItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { Slash } from 'lucide-react'; @@ -34,48 +34,50 @@ interface SlashItemProps { * - MCP commands * - User-defined commands */ -export const SlashItem: React.FC = ({ - slash, - onClick, - isExpanded, - timestamp, - timestampFormat, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const hasInstructions = !!slash.instructions; +export const SlashItem = memo( + ({ + slash, + onClick, + isExpanded, + timestamp, + timestampFormat, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }: SlashItemProps): React.JSX.Element => { + const hasInstructions = !!slash.instructions; - // Display args or message as the description - const description = slash.args ?? slash.message; + // Display args or message as the description + const description = slash.args ?? slash.message; - return ( - } - label={`/${slash.name}`} - summary={description} - tokenCount={slash.instructionsTokenCount} - tokenLabel="tokens" - status={hasInstructions ? 'ok' : undefined} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - hasExpandableContent={hasInstructions} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - {hasInstructions && ( - - )} - - ); -}; + return ( + } + label={`/${slash.name}`} + summary={description} + tokenCount={slash.instructionsTokenCount} + tokenLabel="tokens" + status={hasInstructions ? 'ok' : undefined} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + hasExpandableContent={hasInstructions} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {hasInstructions && ( + + )} + + ); + } +); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index bd5bc904..6e2af8e4 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { CARD_BG, @@ -75,187 +75,192 @@ function isResendMessage(message: TeammateMessage): boolean { * * Operational noise (idle/shutdown/terminated) renders as minimal inline text. */ -export const TeammateMessageItem: React.FC = ({ - teammateMessage, - onClick, - isExpanded, - onReplyHover, - highlightClasses = '', - highlightStyle, -}) => { - const colors = getTeamColorSet(teammateMessage.color); - const { isLight } = useTheme(); +export const TeammateMessageItem = memo( + ({ + teammateMessage, + onClick, + isExpanded, + onReplyHover, + highlightClasses = '', + highlightStyle, + }: TeammateMessageItemProps): React.JSX.Element => { + const colors = getTeamColorSet(teammateMessage.color); + const { isLight } = useTheme(); - // Get team members for @mention highlighting - const members = useStore( - useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) - ); - const memberColorMap = useMemo( - () => (members ? buildMemberColorMap(members) : new Map()), - [members] - ); - - // Get team names for @team linkification - const teams = useStore(useShallow((s) => s.teams)); - const teamNames = useMemo( - () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), - [teams] - ); - - // Detect operational noise - const noiseLabel = useMemo( - () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), - [teammateMessage.content, teammateMessage.teammateId] - ); - - // Detect resent/duplicate messages - const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]); - - const plainSummary = useMemo( - () => extractMarkdownPlainText(teammateMessage.summary), - [teammateMessage.summary] - ); - const plainReplyToSummary = useMemo( - () => - teammateMessage.replyToSummary - ? extractMarkdownPlainText(teammateMessage.replyToSummary) - : undefined, - [teammateMessage.replyToSummary] - ); - - const displayContent = useMemo(() => { - const stripped = stripAgentBlocks(teammateMessage.content); - return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames); - }, [teammateMessage.content, memberColorMap, teamNames]); - - // Noise: minimal inline row (no card, no expand) - if (noiseLabel) { - return ( -
- - - {teammateMessage.teammateId} - - - {noiseLabel} - -
+ // Get team members for @mention highlighting + const members = useStore( + useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) + ); + const memberColorMap = useMemo( + () => (members ? buildMemberColorMap(members) : new Map()), + [members] ); - } - const truncatedSummary = - plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; + // Get team names for @team linkification + const teams = useStore(useShallow((s) => s.teams)); + const teamNames = useMemo( + () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), + [teams] + ); - return ( -
- {/* Header */} + // Detect operational noise + const noiseLabel = useMemo( + () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), + [teammateMessage.content, teammateMessage.teammateId] + ); + + // Detect resent/duplicate messages + const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]); + + const plainSummary = useMemo( + () => extractMarkdownPlainText(teammateMessage.summary), + [teammateMessage.summary] + ); + const plainReplyToSummary = useMemo( + () => + teammateMessage.replyToSummary + ? extractMarkdownPlainText(teammateMessage.replyToSummary) + : undefined, + [teammateMessage.replyToSummary] + ); + + const displayContent = useMemo(() => { + const stripped = stripAgentBlocks(teammateMessage.content); + return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames); + }, [teammateMessage.content, memberColorMap, teamNames]); + + // Noise: minimal inline row (no card, no expand) + if (noiseLabel) { + return ( +
+ + + {teammateMessage.teammateId} + + + {noiseLabel} + +
+ ); + } + + const truncatedSummary = + plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; + + return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} style={{ - backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', - borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: CARD_BG, + border: CARD_BORDER_STYLE, + borderLeft: `3px solid ${colors.border}`, + opacity: isResend ? 0.6 : undefined, + ...highlightStyle, }} > - - - {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} - - - {/* Teammate name badge */} - { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" style={{ - backgroundColor: getThemedBadge(colors, isLight), - color: colors.text, - border: `1px solid ${colors.border}40`, + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', }} > - {teammateMessage.teammateId} - - - {/* "Message" type label — parallels SubagentItem's model info */} - - Message - - - {/* Reply indicator — shows which SendMessage triggered this response */} - {plainReplyToSummary && ( - onReplyHover?.(teammateMessage.replyToToolId ?? null)} - onMouseLeave={() => onReplyHover?.(null)} + /> + + {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} + + + {/* Teammate name badge */} + - - - {plainReplyToSummary} + {teammateMessage.teammateId} + + + {/* "Message" type label — parallels SubagentItem's model info */} + + Message + + + {/* Reply indicator — shows which SendMessage triggered this response */} + {plainReplyToSummary && ( + onReplyHover?.(teammateMessage.replyToToolId ?? null)} + onMouseLeave={() => onReplyHover?.(null)} + > + + + {plainReplyToSummary} + + )} + + {/* Resend badge — marks duplicate/resent messages */} + {isResend && ( + + + Resent + + )} + + {/* Summary */} + + {truncatedSummary || 'Teammate message'} - )} - {/* Resend badge — marks duplicate/resent messages */} - {isResend && ( - - - Resent - - )} + {/* Context impact — tokens injected into main session */} + {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( + + ~{formatTokensCompact(teammateMessage.tokenCount)} tokens + + )} - {/* Summary */} - - {truncatedSummary || 'Teammate message'} - - - {/* Context impact — tokens injected into main session */} - {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( + {/* Timestamp — rightmost info element */} - ~{formatTokensCompact(teammateMessage.tokenCount)} tokens + {format(teammateMessage.timestamp, 'HH:mm:ss')} - )} - - {/* Timestamp — rightmost info element */} - - {format(teammateMessage.timestamp, 'HH:mm:ss')} - -
- - {/* Expanded content */} - {isExpanded && ( -
-
- )} -
- ); -}; + + {/* Expanded content */} + {isExpanded && ( +
+ +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx index de6fb699..f0d9bc9b 100644 --- a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -5,7 +5,7 @@ * 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'; @@ -18,40 +18,44 @@ interface CollapsibleOutputSectionProps { label?: string; } -export const CollapsibleOutputSection: React.FC = ({ - status, - children, - label = 'Output', -}) => { - const [isExpanded, setIsExpanded] = useState(false); +export const CollapsibleOutputSection = memo( + ({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => { + const [isExpanded, setIsExpanded] = useState(false); - return ( -
- - {isExpanded && ( -
+
- )} -
- ); -}; + {isExpanded ? : } + {label} + + + {isExpanded && ( +
+ {children} +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index fc9ae282..8c4ba316 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -4,7 +4,7 @@ * Default rendering for tools that don't have specialized viewers. */ -import React from 'react'; +import React, { memo } from 'react'; import { type ItemStatus } from '../BaseItem'; @@ -23,7 +23,10 @@ interface DefaultToolViewerProps { status: ItemStatus; } -export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { +export const DefaultToolViewer = memo(function DefaultToolViewer({ + linkedTool, + status, +}: DefaultToolViewerProps) { const displayOutputContent = linkedTool.result ? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content) : null; @@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC = ({ linkedTool )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx index b8a8ef7d..c537eb04 100644 --- a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Edit tool with DiffViewer. */ -import React from 'react'; +import React, { memo } from 'react'; import { DiffViewer } from '@renderer/components/chat/viewers'; @@ -20,7 +20,10 @@ interface EditToolViewerProps { status: ItemStatus; } -export const EditToolViewer: React.FC = ({ linkedTool, status }) => { +export const EditToolViewer = memo(function EditToolViewer({ + linkedTool, + status, +}: EditToolViewerProps) { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); @@ -71,4 +74,4 @@ export const EditToolViewer: React.FC = ({ linkedTool, stat )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index 4edd8c93..c2d14b6b 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Read tool result using CodeBlockViewer. */ -import React from 'react'; +import React, { memo } from 'react'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; @@ -14,7 +14,7 @@ interface ReadToolViewerProps { linkedTool: LinkedToolItem; } -export const ReadToolViewer: React.FC = ({ linkedTool }) => { +export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) { const filePath = linkedTool.input.file_path as string; // Prefer enriched toolUseResult data @@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => : undefined; 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 (
@@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => )}
); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx index c6447d79..9c180b28 100644 --- a/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx @@ -4,7 +4,7 @@ * 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'; @@ -14,7 +14,7 @@ interface SkillToolViewerProps { linkedTool: LinkedToolItem; } -export const SkillToolViewer: React.FC = ({ linkedTool }) => { +export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) { const skillInstructions = linkedTool.skillInstructions; const skillName = (linkedTool.input.skill as string) || 'Unknown Skill'; @@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC = ({ linkedTool }) )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx index 13f3fd76..c52456ef 100644 --- a/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx +++ b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx @@ -4,7 +4,7 @@ * Displays error output for tool results. */ -import React from 'react'; +import React, { memo } from 'react'; import { StatusDot } from '../BaseItem'; @@ -16,7 +16,9 @@ interface ToolErrorDisplayProps { linkedTool: LinkedToolItem; } -export const ToolErrorDisplay: React.FC = ({ linkedTool }) => { +export const ToolErrorDisplay = memo(function ToolErrorDisplay({ + linkedTool, +}: ToolErrorDisplayProps) { if (!linkedTool.result?.isError) return null; return ( @@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC = ({ linkedTool } ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index c2931cff..6402a880 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Write tool result. */ -import React from 'react'; +import React, { memo } from 'react'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; @@ -14,7 +14,7 @@ interface WriteToolViewerProps { linkedTool: LinkedToolItem; } -export const WriteToolViewer: React.FC = ({ linkedTool }) => { +export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: WriteToolViewerProps) { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const filePath = @@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) )} ); -}; +}); diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 3782434e..9b78b3a4 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -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 { cn } from '@renderer/lib/utils'; @@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps { children: React.ReactNode; } -export const CollapsibleTeamSection = ({ +export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({ title, icon, badge, @@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({ headerSurfaceClassName, keepMounted, children, -}: CollapsibleTeamSectionProps): React.JSX.Element => { +}: CollapsibleTeamSectionProps): React.JSX.Element { const [open, setOpen] = useState(defaultOpen); const isOpen = forceOpen ? true : open; const sectionRef = useRef(null); @@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({ )} ); -}; +}); diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 97471ad5..b777c22f 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -65,12 +65,12 @@ interface TaskTooltipProps { * Tooltip that shows task summary on hover over any #taskId link. * Reads task data from the current team in the store. */ -export const TaskTooltip = ({ +export const TaskTooltip = memo(function TaskTooltip({ taskId, teamName, children, side = 'top', -}: TaskTooltipProps): React.JSX.Element => { +}: TaskTooltipProps): React.JSX.Element { const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = useStore( useShallow((s) => ({ @@ -194,4 +194,4 @@ export const TaskTooltip = ({ ); -}; +});