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 {
|
import {
|
||||||
getTeamColorSet,
|
getTeamColorSet,
|
||||||
|
|
@ -37,81 +37,83 @@ interface MemberBadgeProps {
|
||||||
* When onClick is provided, both avatar and badge are clickable as one unit.
|
* When onClick is provided, both avatar and badge are clickable as one unit.
|
||||||
* Wrapped in MemberHoverCard to show member info on hover.
|
* Wrapped in MemberHoverCard to show member info on hover.
|
||||||
*/
|
*/
|
||||||
export const MemberBadge = ({
|
export const MemberBadge = memo(
|
||||||
name,
|
({
|
||||||
color,
|
name,
|
||||||
teamName,
|
color,
|
||||||
size = 'sm',
|
teamName,
|
||||||
hideAvatar,
|
size = 'sm',
|
||||||
onClick,
|
hideAvatar,
|
||||||
disableHoverCard,
|
onClick,
|
||||||
}: MemberBadgeProps): React.JSX.Element => {
|
disableHoverCard,
|
||||||
const colors = getTeamColorSet(color ?? '');
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const colors = getTeamColorSet(color ?? '');
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const { isLight } = useTheme();
|
||||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const teamMembers = useStore((s) =>
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
|
const teamMembers = useStore((s) =>
|
||||||
);
|
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
);
|
||||||
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
||||||
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
||||||
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
|
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 = {
|
const badgeStyle = {
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
color: getThemedText(colors, isLight),
|
color: getThemedText(colors, isLight),
|
||||||
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatar = (
|
const avatar = (
|
||||||
<img
|
<img
|
||||||
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const badge = (
|
const badge = (
|
||||||
<span
|
<span
|
||||||
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
|
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
|
||||||
style={badgeStyle}
|
style={badgeStyle}
|
||||||
>
|
>
|
||||||
{displayMemberName(name)}
|
{displayMemberName(name)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skip hover card for "user" and "system" pseudo-members
|
// Skip hover card for "user" and "system" pseudo-members
|
||||||
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
|
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
|
||||||
|
|
||||||
const content = onClick ? (
|
const content = onClick ? (
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick(name);
|
onClick(name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!hideAvatar && avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{!hideAvatar && avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (skipHoverCard) {
|
if (skipHoverCard) {
|
||||||
return content;
|
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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps {
|
||||||
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
||||||
const LONG_QUOTE_THRESHOLD = 200;
|
const LONG_QUOTE_THRESHOLD = 200;
|
||||||
|
|
||||||
export const ReplyQuoteBlock = ({
|
export const ReplyQuoteBlock = memo(
|
||||||
reply,
|
({
|
||||||
memberColor,
|
reply,
|
||||||
bodyMaxHeight = 'max-h-56',
|
memberColor,
|
||||||
replyTaskRefs,
|
bodyMaxHeight = 'max-h-56',
|
||||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
replyTaskRefs,
|
||||||
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Quote block — styled like SendMessageDialog */}
|
{/* 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">
|
<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 */}
|
{/* 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 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>
|
</span>
|
||||||
|
|
||||||
{/* "Replying to" + MemberBadge */}
|
{/* "Replying to" + MemberBadge */}
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<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>
|
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
|
||||||
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Quote text */}
|
{/* Reply text */}
|
||||||
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
|
<MarkdownViewer
|
||||||
<MarkdownViewer
|
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
|
||||||
content={linkifyTaskIdsInMarkdown(reply.originalText)}
|
maxHeight={bodyMaxHeight}
|
||||||
bare
|
copyable
|
||||||
maxHeight={quoteMaxHeight}
|
bare
|
||||||
/>
|
/>
|
||||||
</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>
|
</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 { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
|
|
@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps {
|
||||||
* Inline indicator showing a spinning loader + "working on" + task label button.
|
* Inline indicator showing a spinning loader + "working on" + task label button.
|
||||||
* Shared between MemberCard and MemberHoverCard.
|
* Shared between MemberCard and MemberHoverCard.
|
||||||
*/
|
*/
|
||||||
export const CurrentTaskIndicator = ({
|
export const CurrentTaskIndicator = memo(
|
||||||
task,
|
({
|
||||||
borderColor,
|
task,
|
||||||
maxSubjectLength,
|
borderColor,
|
||||||
activityLabel = 'working on',
|
maxSubjectLength,
|
||||||
onOpenTask,
|
activityLabel = 'working on',
|
||||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
onOpenTask,
|
||||||
const subjectText =
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||||
typeof maxSubjectLength === 'number' &&
|
const subjectText =
|
||||||
maxSubjectLength > 0 &&
|
typeof maxSubjectLength === 'number' &&
|
||||||
task.subject.length > maxSubjectLength
|
maxSubjectLength > 0 &&
|
||||||
? `${task.subject.slice(0, maxSubjectLength)}…`
|
task.subject.length > maxSubjectLength
|
||||||
: task.subject;
|
? `${task.subject.slice(0, maxSubjectLength)}…`
|
||||||
|
: task.subject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
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"
|
title="Open task"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTask?.();
|
onOpenTask?.();
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||||
{formatTaskDisplayLabel(task)} {subjectText}
|
e.preventDefault();
|
||||||
</button>
|
e.stopPropagation();
|
||||||
</div>
|
onOpenTask?.();
|
||||||
);
|
}
|
||||||
};
|
}}
|
||||||
|
>
|
||||||
|
{formatTaskDisplayLabel(task)} {subjectText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
||||||
|
|
@ -8,21 +10,20 @@ interface MemberPresenceDotProps {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberPresenceDot = ({
|
export const MemberPresenceDot = memo(
|
||||||
className,
|
({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
|
||||||
label,
|
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
||||||
}: MemberPresenceDotProps): React.JSX.Element => {
|
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
||||||
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
|
||||||
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
|
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={syncedPulseStyle}
|
style={syncedPulseStyle}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue