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,135 +79,140 @@ 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, ({
label, icon,
summary, label,
tokenCount, summary,
tokenLabel = 'tokens', tokenCount,
status, tokenLabel = 'tokens',
durationMs, status,
timestamp, durationMs,
timestampFormat = 'HH:mm:ss', timestamp,
titleText, timestampFormat = 'HH:mm:ss',
onClick, titleText,
isExpanded, onClick,
hasExpandableContent = true, isExpanded,
highlightClasses = '', hasExpandableContent = true,
highlightStyle, highlightClasses = '',
notificationDotColor, highlightStyle,
children, notificationDotColor,
}) => { children,
return ( }: BaseItemProps): React.JSX.Element => {
<div return (
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
style={highlightStyle}
>
{/* Clickable Header */}
<div <div
role="button" className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
tabIndex={0} style={highlightStyle}
title={titleText}
onClick={onClick}
onKeyDown={(e) => {
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 */} {/* Clickable Header */}
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}> <div
{icon} role="button"
</span> tabIndex={0}
title={titleText}
onClick={onClick}
onKeyDown={(e) => {
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 */}
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}>
{icon}
</span>
{/* Label */} {/* Label */}
<span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}> <span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}>
{label} {label}
</span> </span>
{/* Separator and Summary */} {/* Separator and Summary */}
{summary && ( {summary && (
<> <>
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}> <span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
- -
</span>
<span
className="flex-1 truncate text-sm"
style={{ color: 'var(--tool-item-summary)' }}
>
{summary}
</span>
</>
)}
{/* Spacer if no summary */}
{!summary && <span className="flex-1" />}
{/* Token count badge */}
{tokenCount != null && tokenCount > 0 && (
<span
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
style={{
color: TOOL_ITEM_MUTED,
backgroundColor: 'var(--tool-item-badge-bg)',
}}
>
~{formatTokens(tokenCount)} {tokenLabel}
</span> </span>
<span className="flex-1 truncate text-sm" style={{ color: 'var(--tool-item-summary)' }}> )}
{summary}
{/* Status indicator - hidden when notification dot replaces it */}
{status && !notificationDotColor && <StatusDot status={status} />}
{/* Notification dot (replaces status dot when present) */}
{notificationDotColor && (
<span
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
/>
)}
{/* Duration */}
{durationMs !== undefined && (
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
{formatDuration(durationMs)}
</span> </span>
</> )}
)}
{/* Spacer if no summary */} {/* Timestamp — rightmost info element */}
{!summary && <span className="flex-1" />} {timestamp && (
<span
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
style={{ color: TOOL_ITEM_MUTED }}
>
{format(timestamp, timestampFormat)}
</span>
)}
{/* Token count badge */} {/* Expand/collapse chevron */}
{tokenCount != null && tokenCount > 0 && ( {hasExpandableContent && (
<span <ChevronRight
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs" className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
style={{ style={{ color: TOOL_ITEM_MUTED }}
color: TOOL_ITEM_MUTED, />
backgroundColor: 'var(--tool-item-badge-bg)', )}
}} </div>
{/* Expanded Content */}
{isExpanded && children && (
<div
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
style={{ borderLeft: '2px solid var(--color-border)' }}
> >
~{formatTokens(tokenCount)} {tokenLabel} {children}
</span> </div>
)}
{/* Status indicator - hidden when notification dot replaces it */}
{status && !notificationDotColor && <StatusDot status={status} />}
{/* Notification dot (replaces status dot when present) */}
{notificationDotColor && (
<span
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
/>
)}
{/* Duration */}
{durationMs !== undefined && (
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
{formatDuration(durationMs)}
</span>
)}
{/* Timestamp — rightmost info element */}
{timestamp && (
<span
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
style={{ color: TOOL_ITEM_MUTED }}
>
{format(timestamp, timestampFormat)}
</span>
)}
{/* Expand/collapse chevron */}
{hasExpandableContent && (
<ChevronRight
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
style={{ color: TOOL_ITEM_MUTED }}
/>
)} )}
</div> </div>
);
{/* Expanded Content */} }
{isExpanded && children && ( );
<div
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
style={{ borderLeft: '2px solid var(--color-border)' }}
>
{children}
</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,173 +64,175 @@ interface LinkedToolItemProps {
titleText?: string; titleText?: string;
} }
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({ export const LinkedToolItem = memo(
linkedTool, ({
onClick, linkedTool,
isExpanded, onClick,
timestamp, isExpanded,
timestampFormat, timestamp,
searchQueryOverride, timestampFormat,
isHighlighted, searchQueryOverride,
highlightColor, isHighlighted,
notificationDotColor, highlightColor,
registerRef, notificationDotColor,
titleText, registerRef,
}) => { titleText,
const status = getToolStatus(linkedTool); }: LinkedToolItemProps): React.JSX.Element => {
const { isLight } = useTheme(); const status = getToolStatus(linkedTool);
const summary = getToolSummary(linkedTool.name, linkedTool.input); const { isLight } = useTheme();
const normalizedToolName = linkedTool.name.toLowerCase(); const summary = getToolSummary(linkedTool.name, linkedTool.input);
const summaryNode = const normalizedToolName = linkedTool.name.toLowerCase();
searchQueryOverride && searchQueryOverride.trim().length > 0 const summaryNode =
? highlightQueryInText( searchQueryOverride && searchQueryOverride.trim().length > 0
summary, ? highlightQueryInText(
searchQueryOverride, summary,
`${linkedTool.id ?? linkedTool.name}:summary`, searchQueryOverride,
{ `${linkedTool.id ?? linkedTool.name}:summary`,
forceAllActive: true, {
} forceAllActive: true,
) }
: summary; )
const elementRef = useRef<HTMLDivElement>(null); : summary;
const elementRef = useRef<HTMLDivElement>(null);
// Combined ref callback - handles both internal ref and external registration // Combined ref callback - handles both internal ref and external registration
const handleRef = (el: HTMLDivElement | null): void => { const handleRef = (el: HTMLDivElement | null): void => {
// Update internal ref // Update internal ref
(elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el; (elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
// Call external registration if provided // Call external registration if provided
registerRef?.(el); registerRef?.(el);
}; };
// Render teammate_spawned results as a minimal inline row // Render teammate_spawned results as a minimal inline row
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned'; const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
if (isTeammateSpawned) { if (isTeammateSpawned) {
const teamResult = linkedTool.result!.toolUseResult!; const teamResult = linkedTool.result!.toolUseResult!;
const name = (teamResult.name as string) || 'teammate'; const name = (teamResult.name as string) || 'teammate';
const color = (teamResult.color as string) || ''; const color = (teamResult.color as string) || '';
const colors = getTeamColorSet(color); const colors = getTeamColorSet(color);
return ( return (
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5"> <div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} /> <span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
<span <span
className="rounded px-1.5 py-0.5 text-[10px] font-medium" className="rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }} style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
>
{name}
</span>
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
Teammate spawned
</span>
</div>
);
}
// 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 (
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
<span className="size-2 rounded-full bg-zinc-500" />
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
Shutdown requested &rarr;{' '}
<span className="font-medium text-text-secondary">{target}</span>
</span>
</div>
);
}
// 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 (
<div ref={handleRef}>
<BaseItem
icon={
<Wrench
className="size-4"
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
/>
}
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 && <ReadToolViewer linkedTool={linkedTool} />}
{/* Edit tool with DiffViewer */}
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
{/* Write tool */}
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
{/* Skill tool with instructions */}
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
{/* Default rendering for other tools */}
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
{/* Error output for Read tool */}
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
{/* Error output for Write tool */}
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
{/* Orphaned indicator */}
{linkedTool.isOrphaned && (
<div
className="flex items-center gap-2 text-xs italic"
style={{ color: 'var(--tool-item-muted)' }}
> >
<StatusDot status="orphaned" /> {name}
No result received </span>
</div> <span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
)} Teammate spawned
</span>
{/* Timing */}
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
Duration: {formatDuration(linkedTool.durationMs)}
</div> </div>
</BaseItem> );
</div> }
);
}; // 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 (
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
<span className="size-2 rounded-full bg-zinc-500" />
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
Shutdown requested &rarr;{' '}
<span className="font-medium text-text-secondary">{target}</span>
</span>
</div>
);
}
// 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 (
<div ref={handleRef}>
<BaseItem
icon={
<Wrench
className="size-4"
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
/>
}
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 && <ReadToolViewer linkedTool={linkedTool} />}
{/* Edit tool with DiffViewer */}
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
{/* Write tool */}
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
{/* Skill tool with instructions */}
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
{/* Default rendering for other tools */}
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
{/* Error output for Read tool */}
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
{/* Error output for Write tool */}
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
{/* Orphaned indicator */}
{linkedTool.isOrphaned && (
<div
className="flex items-center gap-2 text-xs italic"
style={{ color: 'var(--tool-item-muted)' }}
>
<StatusDot status="orphaned" />
No result received
</div>
)}
{/* Timing */}
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
Duration: {formatDuration(linkedTool.durationMs)}
</div>
</BaseItem>
</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,175 +41,177 @@ 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, ({
lastUsage, mainSessionImpact,
isolatedLabel, lastUsage,
isolatedOverride, isolatedLabel,
phaseBreakdown, isolatedOverride,
}: Readonly<MetricsPillProps>): React.ReactElement | null => { phaseBreakdown,
const [showTooltip, setShowTooltip] = useState(false); }: Readonly<MetricsPillProps>): React.ReactElement | null => {
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({}); const [showTooltip, setShowTooltip] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const containerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
const hasIsolated = const hasIsolated =
isolatedOverride != null isolatedOverride != null
? isolatedOverride > 0 ? isolatedOverride > 0
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
const isolatedTotal = const isolatedTotal =
isolatedOverride ?? isolatedOverride ??
(lastUsage (lastUsage
? lastUsage.input_tokens + ? lastUsage.input_tokens +
lastUsage.output_tokens + lastUsage.output_tokens +
(lastUsage.cache_read_input_tokens ?? 0) + (lastUsage.cache_read_input_tokens ?? 0) +
(lastUsage.cache_creation_input_tokens ?? 0) (lastUsage.cache_creation_input_tokens ?? 0)
: 0); : 0);
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
const clearHideTimeout = (): void => { const clearHideTimeout = (): void => {
if (hideTimeoutRef.current) { if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current); clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null; 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;
} }
setTooltipStyle({ };
position: 'fixed',
bottom: window.innerHeight - rect.top + 6, const handleMouseEnter = (): void => {
left, clearHideTimeout();
width: tooltipWidth, setShowTooltip(true);
zIndex: 99999, };
});
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(() => { const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
if (!showTooltip) return; const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
const handleScroll = (): void => setShowTooltip(false); const rightLabel = isolatedLabel ?? 'Subagent Context';
window.addEventListener('scroll', handleScroll, true);
return () => window.removeEventListener('scroll', handleScroll, true);
}, [showTooltip]);
useEffect(() => { return (
return () => clearHideTimeout(); <>
}, []); <div
ref={containerRef}
role="tooltip"
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]"
style={{
backgroundColor: TAG_BG,
border: `1px solid ${TAG_BORDER}`,
color: TAG_TEXT,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{mainValue && <span className="tabular-nums">{mainValue}</span>}
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
</div>
if (!hasMainImpact && !hasIsolated) { {showTooltip &&
return null; createPortal(
} <div
role="tooltip"
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; style={{
const rightLabel = isolatedLabel ?? 'Subagent Context'; ...tooltipStyle,
border: `1px solid ${TAG_BORDER}`,
return ( }}
<> onMouseEnter={handleMouseEnter}
<div onMouseLeave={handleMouseLeave}
ref={containerRef} >
role="tooltip" <div className="space-y-1">
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]" {hasMainImpact && (
style={{ <div className="flex items-center justify-between gap-3">
backgroundColor: TAG_BG, <span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
border: `1px solid ${TAG_BORDER}`, <span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
color: TAG_TEXT, {mainSessionImpact.totalTokens.toLocaleString()}
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{mainValue && <span className="tabular-nums">{mainValue}</span>}
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
</div>
{showTooltip &&
createPortal(
<div
role="tooltip"
className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
style={{
...tooltipStyle,
border: `1px solid ${TAG_BORDER}`,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="space-y-1">
{hasMainImpact && (
<div className="flex items-center justify-between gap-3">
<span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
{mainSessionImpact.totalTokens.toLocaleString()}
</span>
</div>
)}
{hasIsolated && (
<div className="flex items-center justify-between gap-3">
<span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
{isolatedTotal.toLocaleString()}
</span>
</div>
)}
{hasPhases &&
phaseBreakdown.map((phase) => (
<div
key={phase.phaseNumber}
className="flex items-center justify-between gap-3 pl-2"
>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
Phase {phase.phaseNumber}
</span>
<span
className="font-mono text-[10px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{formatTokensCompact(phase.peakTokens)}
{phase.postCompaction != null && (
<span style={{ color: '#4ade80' }}>
{' '}
→ {formatTokensCompact(phase.postCompaction)}
</span>
)}
</span> </span>
</div> </div>
))} )}
<div {hasIsolated && (
className="mt-1 pt-1.5 text-[10px]" <div className="flex items-center justify-between gap-3">
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }} <span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
> <span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
{hasMainImpact && hasIsolated {isolatedTotal.toLocaleString()}
? 'Left: parent injection · Right: internal' </span>
: hasMainImpact </div>
? 'Tokens injected to parent' )}
: 'Internal token usage'} {hasPhases &&
phaseBreakdown.map((phase) => (
<div
key={phase.phaseNumber}
className="flex items-center justify-between gap-3 pl-2"
>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
Phase {phase.phaseNumber}
</span>
<span
className="font-mono text-[10px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{formatTokensCompact(phase.peakTokens)}
{phase.postCompaction != null && (
<span style={{ color: '#4ade80' }}>
{' '}
→ {formatTokensCompact(phase.postCompaction)}
</span>
)}
</span>
</div>
))}
<div
className="mt-1 pt-1.5 text-[10px]"
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
>
{hasMainImpact && hasIsolated
? 'Left: parent injection · Right: internal'
: hasMainImpact
? 'Tokens injected to parent'
: 'Internal token usage'}
</div>
</div> </div>
</div> </div>,
</div>, document.body
document.body )}
)} </>
</> );
); }
}; );

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,48 +34,50 @@ interface SlashItemProps {
* - MCP commands * - MCP commands
* - User-defined commands * - User-defined commands
*/ */
export const SlashItem: React.FC<SlashItemProps> = ({ export const SlashItem = memo(
slash, ({
onClick, slash,
isExpanded, onClick,
timestamp, isExpanded,
timestampFormat, timestamp,
highlightClasses, timestampFormat,
highlightStyle, highlightClasses,
notificationDotColor, highlightStyle,
titleText, notificationDotColor,
}) => { titleText,
const hasInstructions = !!slash.instructions; }: SlashItemProps): React.JSX.Element => {
const hasInstructions = !!slash.instructions;
// Display args or message as the description // Display args or message as the description
const description = slash.args ?? slash.message; const description = slash.args ?? slash.message;
return ( return (
<BaseItem <BaseItem
icon={<Slash className="size-4" />} icon={<Slash className="size-4" />}
label={`/${slash.name}`} label={`/${slash.name}`}
summary={description} summary={description}
tokenCount={slash.instructionsTokenCount} tokenCount={slash.instructionsTokenCount}
tokenLabel="tokens" tokenLabel="tokens"
status={hasInstructions ? 'ok' : undefined} status={hasInstructions ? 'ok' : undefined}
timestamp={timestamp} timestamp={timestamp}
timestampFormat={timestampFormat} timestampFormat={timestampFormat}
titleText={titleText} titleText={titleText}
onClick={onClick} onClick={onClick}
isExpanded={isExpanded} isExpanded={isExpanded}
hasExpandableContent={hasInstructions} hasExpandableContent={hasInstructions}
highlightClasses={highlightClasses} highlightClasses={highlightClasses}
highlightStyle={highlightStyle} highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor} notificationDotColor={notificationDotColor}
> >
{hasInstructions && ( {hasInstructions && (
<MarkdownViewer <MarkdownViewer
content={slash.instructions!} content={slash.instructions!}
label="Slash Output" label="Slash Output"
maxHeight="max-h-96" maxHeight="max-h-96"
copyable copyable
/> />
)} )}
</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,187 +75,192 @@ 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, ({
onClick, teammateMessage,
isExpanded, onClick,
onReplyHover, isExpanded,
highlightClasses = '', onReplyHover,
highlightStyle, highlightClasses = '',
}) => { highlightStyle,
const colors = getTeamColorSet(teammateMessage.color); }: TeammateMessageItemProps): React.JSX.Element => {
const { isLight } = useTheme(); const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme();
// Get team members for @mention highlighting // Get team members for @mention highlighting
const members = useStore( const members = useStore(
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
); );
const memberColorMap = useMemo( const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()), () => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members] [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 (
<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="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{teammateMessage.teammateId}
</span>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{noiseLabel}
</span>
</div>
); );
}
const truncatedSummary = // Get team names for @team linkification
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; const teams = useStore(useShallow((s) => s.teams));
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
);
return ( // Detect operational noise
<div const noiseLabel = useMemo(
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
style={{ [teammateMessage.content, teammateMessage.teammateId]
backgroundColor: CARD_BG, );
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`, // Detect resent/duplicate messages
opacity: isResend ? 0.6 : undefined, const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
...highlightStyle,
}} const plainSummary = useMemo(
> () => extractMarkdownPlainText(teammateMessage.summary),
{/* Header */} [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 (
<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="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{teammateMessage.teammateId}
</span>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{noiseLabel}
</span>
</div>
);
}
const truncatedSummary =
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
return (
<div <div
role="button" className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
style={{ style={{
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', backgroundColor: CARD_BG,
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
opacity: isResend ? 0.6 : undefined,
...highlightStyle,
}} }}
> >
<ChevronRight {/* Header */}
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} <div
style={{ color: CARD_ICON_MUTED }} role="button"
/> tabIndex={0}
onClick={onClick}
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} onKeyDown={(e) => {
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} /> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
{/* Teammate name badge */} onClick();
<span }
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide" }}
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
style={{ style={{
backgroundColor: getThemedBadge(colors, isLight), backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
color: colors.text, borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
border: `1px solid ${colors.border}40`,
}} }}
> >
{teammateMessage.teammateId} <ChevronRight
</span> className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
{/* "Message" type label — parallels SubagentItem's model info */}
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
Message
</span>
{/* Reply indicator — shows which SendMessage triggered this response */}
{plainReplyToSummary && (
<span
role="presentation"
className="flex cursor-default items-center gap-1 text-[10px]"
style={{ color: CARD_ICON_MUTED }} style={{ color: CARD_ICON_MUTED }}
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)} />
onMouseLeave={() => onReplyHover?.(null)}
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
{/* Teammate name badge */}
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
> >
<CornerDownLeft className="size-2.5" /> {teammateMessage.teammateId}
<span className="truncate" style={{ maxWidth: '180px' }}> </span>
{plainReplyToSummary}
{/* "Message" type label — parallels SubagentItem's model info */}
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
Message
</span>
{/* Reply indicator — shows which SendMessage triggered this response */}
{plainReplyToSummary && (
<span
role="presentation"
className="flex cursor-default items-center gap-1 text-[10px]"
style={{ color: CARD_ICON_MUTED }}
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
onMouseLeave={() => onReplyHover?.(null)}
>
<CornerDownLeft className="size-2.5" />
<span className="truncate" style={{ maxWidth: '180px' }}>
{plainReplyToSummary}
</span>
</span> </span>
)}
{/* Resend badge — marks duplicate/resent messages */}
{isResend && (
<span
className="flex items-center gap-0.5 text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
<RefreshCw className="size-2.5" />
Resent
</span>
)}
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{truncatedSummary || 'Teammate message'}
</span> </span>
)}
{/* Resend badge — marks duplicate/resent messages */} {/* Context impact — tokens injected into main session */}
{isResend && ( {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
<span <span
className="flex items-center gap-0.5 text-[10px]" className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }} style={{ color: CARD_ICON_MUTED }}
> >
<RefreshCw className="size-2.5" /> ~{formatTokensCompact(teammateMessage.tokenCount)} tokens
Resent </span>
</span> )}
)}
{/* Summary */} {/* Timestamp — rightmost info element */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{truncatedSummary || 'Teammate message'}
</span>
{/* Context impact — tokens injected into main session */}
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
<span <span
className="shrink-0 font-mono text-[11px] tabular-nums" className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }} style={{ color: CARD_ICON_MUTED }}
> >
~{formatTokensCompact(teammateMessage.tokenCount)} tokens {format(teammateMessage.timestamp, 'HH:mm:ss')}
</span> </span>
)}
{/* Timestamp — rightmost info element */}
<span
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{format(teammateMessage.timestamp, 'HH:mm:ss')}
</span>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="p-3">
<MarkdownViewer content={displayContent} copyable />
</div> </div>
)}
</div> {/* Expanded content */}
); {isExpanded && (
}; <div className="p-3">
<MarkdownViewer content={displayContent} copyable />
</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,40 +18,44 @@ 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, const [isExpanded, setIsExpanded] = useState(false);
label = 'Output',
}) => {
const [isExpanded, setIsExpanded] = useState(false);
return ( return (
<div> <div>
<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' }}
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{label}
<StatusDot status={status} />
</button>
{isExpanded && (
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{ style={{
backgroundColor: 'var(--code-bg)', color: 'var(--tool-item-muted)',
border: '1px solid var(--code-border)', background: 'none',
color: border: 'none',
status === 'error' padding: 0,
? 'var(--tool-result-error-text)' cursor: 'pointer',
: 'var(--color-text-secondary)',
}} }}
onClick={() => setIsExpanded((prev) => !prev)}
> >
{children} {isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</div> {label}
)} <StatusDot status={status} />
</div> </button>
); {isExpanded && (
}; <div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color:
status === 'error'
? 'var(--tool-result-error-text)'
: 'var(--color-text-secondary)',
}}
>
{children}
</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>
); );
}; });