perf(renderer): lazy render member hover cards

This commit is contained in:
777genius 2026-05-30 20:50:17 +03:00
parent d59865f300
commit 2c13516d9f
2 changed files with 198 additions and 114 deletions

View file

@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useMemo, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer'; import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
@ -59,9 +59,7 @@ interface MemberHoverCardProps {
} }
/** /**
* Wraps children in a HoverCard that shows member info on hover. * Wraps children in a HoverCard that mounts detailed member data only while open.
* Reads member data from the team snapshot + resolved member selectors.
* Falls back to a simple wrapper when member data is unavailable.
*/ */
export const MemberHoverCard = memo(function MemberHoverCard({ export const MemberHoverCard = memo(function MemberHoverCard({
name, name,
@ -70,10 +68,39 @@ export const MemberHoverCard = memo(function MemberHoverCard({
onOpenTask, onOpenTask,
children, children,
}: MemberHoverCardProps): React.JSX.Element { }: MemberHoverCardProps): React.JSX.Element {
const [open, setOpen] = useState(false);
return (
<HoverCard open={open} onOpenChange={setOpen} openDelay={300} closeDelay={200}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
{open ? (
<MemberHoverCardContent
name={name}
color={color}
teamName={teamName}
onOpenTask={onOpenTask}
/>
) : null}
</HoverCard>
);
});
interface MemberHoverCardContentProps {
name: string;
color?: string;
teamName?: string;
onOpenTask?: (task: TeamTaskWithKanban) => void;
}
const MemberHoverCardContent = ({
name,
color,
teamName,
onOpenTask,
}: MemberHoverCardContentProps): React.JSX.Element | null => {
const { t } = useAppTranslation('team'); const { t } = useAppTranslation('team');
const { isLight } = useTheme(); const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName); const effectiveTeamName = useStore((s) => teamName ?? s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const { const {
member, member,
teamMembers, teamMembers,
@ -120,10 +147,10 @@ export const MemberHoverCard = memo(function MemberHoverCard({
})) }))
); );
const openMemberProfile = useStore((s) => s.openMemberProfile); const openMemberProfile = useStore((s) => s.openMemberProfile);
const avatarMap = buildMemberAvatarMap(teamMembers); const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
if (!member) { if (!member) {
return <>{children}</>; return null;
} }
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
@ -238,112 +265,109 @@ export const MemberHoverCard = memo(function MemberHoverCard({
: null; : null;
return ( return (
<HoverCard openDelay={300} closeDelay={200}> <HoverCardContent side="top" align="start" sideOffset={8}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger> <div className="flex flex-col gap-2.5">
<HoverCardContent side="top" align="start" sideOffset={8}> {/* Header: avatar + name + presence */}
<div className="flex flex-col gap-2.5"> <div className="flex items-center gap-3">
{/* Header: avatar + name + presence */} <div className="relative shrink-0">
<div className="flex items-center gap-3"> <img
<div className="relative shrink-0"> src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
<img alt={member.name}
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)} className="size-10 rounded-full bg-[var(--color-surface-raised)]"
alt={member.name} loading="lazy"
className="size-10 rounded-full bg-[var(--color-surface-raised)]" />
loading="lazy" <MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
/>
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-semibold"
style={{ color: getThemedText(colors, isLight) }}
>
{displayMemberName(member.name)}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
title={runtimeAdvisoryTitle}
style={{
backgroundColor:
runtimeAdvisoryTone === 'error'
? 'rgba(239, 68, 68, 0.16)'
: getThemedBadge(colors, isLight),
color:
runtimeAdvisoryTone === 'error'
? 'rgb(252, 165, 165)'
: getThemedText(colors, isLight),
border:
runtimeAdvisoryTone === 'error'
? '1px solid rgba(248, 113, 113, 0.35)'
: `1px solid ${getThemedBorder(colors, isLight)}40`,
}}
>
{badgeLabel}
</Badge>
</div>
{roleLabel && (
<span className="text-xs text-[var(--color-text-muted)]">{roleLabel}</span>
)}
</div>
</div> </div>
<div className="min-w-0 flex-1">
{/* Current task */} <div className="flex items-center gap-1.5">
{currentTask && ( <span
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5"> className="truncate text-sm font-semibold"
<CurrentTaskIndicator style={{ color: getThemedText(colors, isLight) }}
task={currentTask} >
borderColor={colors.border} {displayMemberName(member.name)}
maxSubjectLength={28}
activityLabel="working on"
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
/>
</div>
)}
{/* Review task */}
{reviewTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="reviewing"
onOpenTask={onOpenTask ? () => onOpenTask(reviewTask) : undefined}
/>
</div>
)}
{launchErrorMessage ? (
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
{launchErrorMessage}
</span> </span>
{showCopyDiagnostics ? ( <Badge
<MemberLaunchDiagnosticsButton variant="secondary"
payload={launchDiagnosticsPayload} className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200" title={runtimeAdvisoryTitle}
/> style={{
) : null} backgroundColor:
runtimeAdvisoryTone === 'error'
? 'rgba(239, 68, 68, 0.16)'
: getThemedBadge(colors, isLight),
color:
runtimeAdvisoryTone === 'error'
? 'rgb(252, 165, 165)'
: getThemedText(colors, isLight),
border:
runtimeAdvisoryTone === 'error'
? '1px solid rgba(248, 113, 113, 0.35)'
: `1px solid ${getThemedBorder(colors, isLight)}40`,
}}
>
{badgeLabel}
</Badge>
</div> </div>
) : null} {roleLabel && (
<span className="text-xs text-[var(--color-text-muted)]">{roleLabel}</span>
<div className="flex gap-1.5"> )}
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
{t('members.actions.openProfile')}
</button>
</div> </div>
</div> </div>
</HoverCardContent>
</HoverCard> {/* Current task */}
{currentTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="working on"
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
/>
</div>
)}
{/* Review task */}
{reviewTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="reviewing"
onOpenTask={onOpenTask ? () => onOpenTask(reviewTask) : undefined}
/>
</div>
)}
{launchErrorMessage ? (
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
{launchErrorMessage}
</span>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</div>
) : null}
<div className="flex gap-1.5">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
{t('members.actions.openProfile')}
</button>
</div>
</div>
</HoverCardContent>
); );
}); };

View file

@ -1,5 +1,6 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -68,8 +69,25 @@ const storeState = {
openMemberProfile: vi.fn(), openMemberProfile: vi.fn(),
}; };
const hoverCardMockState = vi.hoisted(() => ({
autoOpen: true,
wideSelectorCalls: 0,
}));
vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), useStore: (selector: (state: typeof storeState) => unknown) => {
const selected = selector(storeState);
if (
selected &&
typeof selected === 'object' &&
'member' in selected &&
'teamMembers' in selected &&
'runtimeEntry' in selected
) {
hoverCardMockState.wideSelectorCalls += 1;
}
return selected;
},
})); }));
vi.mock('@renderer/store/slices/teamSlice', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({
@ -100,8 +118,22 @@ vi.mock('@renderer/components/ui/badge', () => ({
})); }));
vi.mock('@renderer/components/ui/hover-card', () => ({ vi.mock('@renderer/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: React.ReactNode }) => HoverCard: ({
React.createElement('div', null, children), children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
React.useEffect(() => {
if (hoverCardMockState.autoOpen && open !== true) {
onOpenChange?.(true);
}
}, [onOpenChange, open]);
return React.createElement('div', { 'data-hover-card-open': open ? 'true' : 'false' }, children);
},
HoverCardTrigger: ({ children }: { children: React.ReactNode }) => HoverCardTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children), React.createElement(React.Fragment, null, children),
HoverCardContent: ({ children }: { children: React.ReactNode }) => HoverCardContent: ({ children }: { children: React.ReactNode }) =>
@ -155,6 +187,34 @@ describe('MemberHoverCard spawn-aware presence', () => {
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
storeState.teamAgentRuntimeByTeam = {}; storeState.teamAgentRuntimeByTeam = {};
storeState.openMemberProfile.mockReset(); storeState.openMemberProfile.mockReset();
hoverCardMockState.autoOpen = true;
hoverCardMockState.wideSelectorCalls = 0;
});
it('does not run detailed store selectors while closed', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoverCardMockState.autoOpen = false;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberHoverCard, {
name: 'alice',
children: React.createElement('button', { type: 'button' }, 'alice'),
})
);
await Promise.resolve();
});
expect(host.textContent).toBe('alice');
expect(hoverCardMockState.wideSelectorCalls).toBe(0);
await act(async () => {
root.unmount();
await Promise.resolve();
});
}); });
it('shows starting from the team spawn snapshot even when provisioning is no longer active', async () => { it('shows starting from the team spawn snapshot even when provisioning is no longer active', async () => {