diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 8957e3d3..41d4f6b6 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { Badge } from '@renderer/components/ui/badge'; @@ -59,9 +59,7 @@ interface MemberHoverCardProps { } /** - * Wraps children in a HoverCard that shows member info on hover. - * Reads member data from the team snapshot + resolved member selectors. - * Falls back to a simple wrapper when member data is unavailable. + * Wraps children in a HoverCard that mounts detailed member data only while open. */ export const MemberHoverCard = memo(function MemberHoverCard({ name, @@ -70,10 +68,39 @@ export const MemberHoverCard = memo(function MemberHoverCard({ onOpenTask, children, }: MemberHoverCardProps): React.JSX.Element { + const [open, setOpen] = useState(false); + + return ( + + {children} + {open ? ( + + ) : null} + + ); +}); + +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 { isLight } = useTheme(); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const effectiveTeamName = teamName ?? selectedTeamName; + const effectiveTeamName = useStore((s) => teamName ?? s.selectedTeamName); const { member, teamMembers, @@ -120,10 +147,10 @@ export const MemberHoverCard = memo(function MemberHoverCard({ })) ); const openMemberProfile = useStore((s) => s.openMemberProfile); - const avatarMap = buildMemberAvatarMap(teamMembers); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); if (!member) { - return <>{children}; + return null; } const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ @@ -238,112 +265,109 @@ export const MemberHoverCard = memo(function MemberHoverCard({ : null; return ( - - {children} - -
- {/* Header: avatar + name + presence */} -
-
- {member.name} - -
-
-
- - {displayMemberName(member.name)} - - - {badgeLabel} - -
- {roleLabel && ( - {roleLabel} - )} -
+ +
+ {/* Header: avatar + name + presence */} +
+
+ {member.name} +
- - {/* Current task */} - {currentTask && ( -
- onOpenTask(currentTask) : undefined} - /> -
- )} - - {/* Review task */} - {reviewTask && ( -
- onOpenTask(reviewTask) : undefined} - /> -
- )} - - {launchErrorMessage ? ( -
- - {launchErrorMessage} +
+
+ + {displayMemberName(member.name)} - {showCopyDiagnostics ? ( - - ) : null} + + {badgeLabel} +
- ) : null} - -
- + {roleLabel && ( + {roleLabel} + )}
- - + + {/* Current task */} + {currentTask && ( +
+ onOpenTask(currentTask) : undefined} + /> +
+ )} + + {/* Review task */} + {reviewTask && ( +
+ onOpenTask(reviewTask) : undefined} + /> +
+ )} + + {launchErrorMessage ? ( +
+ + {launchErrorMessage} + + {showCopyDiagnostics ? ( + + ) : null} +
+ ) : null} + +
+ +
+
+ ); -}); +}; diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index 2bb73ef2..95946b2f 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -68,8 +69,25 @@ const storeState = { openMemberProfile: vi.fn(), }; +const hoverCardMockState = vi.hoisted(() => ({ + autoOpen: true, + wideSelectorCalls: 0, +})); + 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', () => ({ @@ -100,8 +118,22 @@ vi.mock('@renderer/components/ui/badge', () => ({ })); vi.mock('@renderer/components/ui/hover-card', () => ({ - HoverCard: ({ children }: { children: React.ReactNode }) => - React.createElement('div', null, children), + HoverCard: ({ + 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 }) => React.createElement(React.Fragment, null, children), HoverCardContent: ({ children }: { children: React.ReactNode }) => @@ -155,6 +187,34 @@ describe('MemberHoverCard spawn-aware presence', () => { storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; storeState.teamAgentRuntimeByTeam = {}; 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 () => {