perf(renderer): lazy render member hover cards
This commit is contained in:
parent
d59865f300
commit
2c13516d9f
2 changed files with 198 additions and 114 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue