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:
parent
8b30930c04
commit
e300d4cbd5
5 changed files with 821 additions and 804 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
“
|
||||
</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]">
|
||||
“
|
||||
</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>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue