perf: memoize MemberBadge, CurrentTaskIndicator, MemberPresenceDot, ReplyQuoteBlock, GlobalTaskList

Prevent unnecessary re-renders on these frequently-rendered components
that appear in MemberCard rows, activity feeds, and the sidebar task list.
This commit is contained in:
Mike 2026-05-02 21:28:17 +05:00
parent 8b30930c04
commit e300d4cbd5
5 changed files with 821 additions and 804 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import {
getTeamColorSet,
@ -37,81 +37,83 @@ interface MemberBadgeProps {
* When onClick is provided, both avatar and badge are clickable as one unit.
* Wrapped in MemberHoverCard to show member info on hover.
*/
export const MemberBadge = ({
name,
color,
teamName,
size = 'sm',
hideAvatar,
onClick,
disableHoverCard,
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const teamMembers = useStore((s) =>
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
export const MemberBadge = memo(
({
name,
color,
teamName,
size = 'sm',
hideAvatar,
onClick,
disableHoverCard,
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const teamMembers = useStore((s) =>
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
);
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
const badgeStyle = {
backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight),
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
};
const badgeStyle = {
backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight),
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
};
const avatar = (
<img
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
);
const avatar = (
<img
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
);
const badge = (
<span
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{displayMemberName(name)}
</span>
);
const badge = (
<span
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{displayMemberName(name)}
</span>
);
// Skip hover card for "user" and "system" pseudo-members
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
// Skip hover card for "user" and "system" pseudo-members
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
const content = onClick ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onClick(name);
}}
>
{!hideAvatar && avatar}
{badge}
</button>
) : (
<span className="inline-flex items-center gap-1">
{!hideAvatar && avatar}
{badge}
</span>
);
const content = onClick ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onClick(name);
}}
>
{!hideAvatar && avatar}
{badge}
</button>
) : (
<span className="inline-flex items-center gap-1">
{!hideAvatar && avatar}
{badge}
</span>
);
if (skipHoverCard) {
return content;
if (skipHoverCard) {
return content;
}
return (
<MemberHoverCard name={name} color={color} teamName={teamName}>
{content}
</MemberHoverCard>
);
}
return (
<MemberHoverCard name={name} color={color} teamName={teamName}>
{content}
</MemberHoverCard>
);
};
);

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps {
/** Threshold (characters) above which the "more/less" toggle is shown. */
const LONG_QUOTE_THRESHOLD = 200;
export const ReplyQuoteBlock = ({
reply,
memberColor,
bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => {
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
export const ReplyQuoteBlock = memo(
({
reply,
memberColor,
bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => {
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
return (
<div className="space-y-2">
{/* Quote block — styled like SendMessageDialog */}
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
{/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
&ldquo;
</span>
return (
<div className="space-y-2">
{/* Quote block — styled like SendMessageDialog */}
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
{/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
&ldquo;
</span>
{/* "Replying to" + MemberBadge */}
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
{/* "Replying to" + MemberBadge */}
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
</div>
{/* Quote text */}
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.originalText)}
bare
maxHeight={quoteMaxHeight}
/>
</div>
{/* More/less toggle */}
{isLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'less' : 'more'}
</button>
) : null}
</div>
{/* Quote text */}
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.originalText)}
bare
maxHeight={quoteMaxHeight}
/>
</div>
{/* More/less toggle */}
{isLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'less' : 'more'}
</button>
) : null}
{/* Reply text */}
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
maxHeight={bodyMaxHeight}
copyable
bare
/>
</div>
{/* Reply text */}
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
maxHeight={bodyMaxHeight}
copyable
bare
/>
</div>
);
};
);
}
);

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps {
* Inline indicator showing a spinning loader + "working on" + task label button.
* Shared between MemberCard and MemberHoverCard.
*/
export const CurrentTaskIndicator = ({
task,
borderColor,
maxSubjectLength,
activityLabel = 'working on',
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const subjectText =
typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
task.subject.length > maxSubjectLength
? `${task.subject.slice(0, maxSubjectLength)}`
: task.subject;
export const CurrentTaskIndicator = memo(
({
task,
borderColor,
maxSubjectLength,
activityLabel = 'working on',
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const subjectText =
typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
task.subject.length > maxSubjectLength
? `${task.subject.slice(0, maxSubjectLength)}`
: task.subject;
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
title="Open task"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
title="Open task"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}
}}
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
</div>
);
};
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
e.stopPropagation();
onOpenTask?.();
}
}}
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
</div>
);
}
);

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
import { cn } from '@renderer/lib/utils';
@ -8,21 +10,20 @@ interface MemberPresenceDotProps {
label: string;
}
export const MemberPresenceDot = ({
className,
label,
}: MemberPresenceDotProps): React.JSX.Element => {
const shouldSyncPulse = className?.includes('animate-pulse') === true;
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
export const MemberPresenceDot = memo(
({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
const shouldSyncPulse = className?.includes('animate-pulse') === true;
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
return (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
className
)}
style={syncedPulseStyle}
aria-label={label}
/>
);
};
return (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
className
)}
style={syncedPulseStyle}
aria-label={label}
/>
);
}
);