From dc1d310df8d2c82097c616b24da1e92916058f67 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 15:46:10 +0300 Subject: [PATCH] fix: restore dev validation after team page merge --- .../components/team/TeamDetailView.tsx | 106 ++++++++++++----- .../team/kanban/KanbanTaskCard.test.tsx | 4 +- .../components/team/members/MemberList.tsx | 31 +++++ .../team/activity/activityRenderCache.test.ts | 2 +- .../team/members/MemberList.test.ts | 107 ++++++++++++++++++ 5 files changed, 221 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 49620a0a..04cb378d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -5,6 +5,7 @@ import { Suspense, useCallback, useEffect, + useId, useImperativeHandle, useMemo, useRef, @@ -177,6 +178,7 @@ import type { SessionInjection } from './session-injection-types'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { + KanbanColumnId, KanbanTaskState, MemberSpawnStatusEntry, ResolvedTeamMember, @@ -292,6 +294,7 @@ interface CreateTaskDialogState { } const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000; +const EMPTY_SESSION_HISTORY: readonly string[] = []; const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200; const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200; @@ -362,18 +365,30 @@ function areResolvedMembersEqual( const nextMember = next[i]; if ( prevMember.name !== nextMember.name || + prevMember.agentId !== nextMember.agentId || prevMember.status !== nextMember.status || prevMember.currentTaskId !== nextMember.currentTaskId || + prevMember.taskCount !== nextMember.taskCount || + prevMember.lastActiveAt !== nextMember.lastActiveAt || + prevMember.messageCount !== nextMember.messageCount || prevMember.color !== nextMember.color || prevMember.agentType !== nextMember.agentType || prevMember.role !== nextMember.role || prevMember.workflow !== nextMember.workflow || + prevMember.isolation !== nextMember.isolation || prevMember.providerId !== nextMember.providerId || + prevMember.providerBackendId !== nextMember.providerBackendId || prevMember.model !== nextMember.model || prevMember.effort !== nextMember.effort || + prevMember.selectedFastMode !== nextMember.selectedFastMode || + prevMember.resolvedFastMode !== nextMember.resolvedFastMode || + prevMember.laneId !== nextMember.laneId || + prevMember.laneKind !== nextMember.laneKind || + prevMember.laneOwnerProviderId !== nextMember.laneOwnerProviderId || prevMember.cwd !== nextMember.cwd || prevMember.gitBranch !== nextMember.gitBranch || prevMember.removedAt !== nextMember.removedAt || + !areMemberMcpPoliciesEqual(prevMember.mcpPolicy, nextMember.mcpPolicy) || prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind || prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt || prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil || @@ -388,6 +403,22 @@ function areResolvedMembersEqual( return true; } +function areMemberMcpPoliciesEqual( + prev: ResolvedTeamMember['mcpPolicy'], + next: ResolvedTeamMember['mcpPolicy'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return prev === next; + return ( + prev.mode === next.mode && + prev.scopes?.user === next.scopes?.user && + prev.scopes?.project === next.scopes?.project && + prev.scopes?.local === next.scopes?.local && + (prev.serverNames ?? []).length === (next.serverNames ?? []).length && + (prev.serverNames ?? []).every((serverName, index) => serverName === next.serverNames?.[index]) + ); +} + function useStableActiveMembers( members: readonly ResolvedTeamMember[] | undefined ): ResolvedTeamMember[] { @@ -935,7 +966,6 @@ interface LeadLoadBridgeProps { const pendingRepliesCacheByTeam = new Map>(); const pendingRepliesListenersByTeam = new Map void>>(); -let pendingReplyRefreshSourceSequence = 0; function getPendingRepliesSnapshot(teamName: string): Record { let snapshot = pendingRepliesCacheByTeam.get(teamName); @@ -1441,11 +1471,8 @@ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({ ...props }: TeamMessagesPanelBridgeProps): React.JSX.Element { const pendingRepliesByMember = useTeamPendingReplies(teamName); - const pendingReplyRefreshSourceId = useRef(null); - if (pendingReplyRefreshSourceId.current === null) { - pendingReplyRefreshSourceSequence += 1; - pendingReplyRefreshSourceId.current = `team-messages:${pendingReplyRefreshSourceSequence}`; - } + const pendingReplyRefreshSourceId = useId(); + const pendingReplyRefreshSourceKey = `team-messages:${pendingReplyRefreshSourceId}`; const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], @@ -1458,15 +1485,21 @@ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({ const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; syncTeamPendingReplyRefresh( teamName, - pendingReplyRefreshSourceId.current!, + pendingReplyRefreshSourceKey, Boolean(isTeamAlive) && hasPendingReplies, TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false); + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceKey, false); }; - }, [isTeamAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]); + }, [ + isTeamAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceKey, + syncTeamPendingReplyRefresh, + teamName, + ]); const handlePendingReplyChange = useCallback( (updater: PendingRepliesUpdater) => { @@ -1494,11 +1527,8 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({ }: TeamSidebarRailBridgeProps): React.JSX.Element { const teamName = messagesPanelProps.teamName; const pendingRepliesByMember = useTeamPendingReplies(teamName); - const pendingReplyRefreshSourceId = useRef(null); - if (pendingReplyRefreshSourceId.current === null) { - pendingReplyRefreshSourceSequence += 1; - pendingReplyRefreshSourceId.current = `team-sidebar:${pendingReplyRefreshSourceSequence}`; - } + const pendingReplyRefreshSourceId = useId(); + const pendingReplyRefreshSourceKey = `team-sidebar:${pendingReplyRefreshSourceId}`; const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], @@ -1510,17 +1540,18 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({ const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; syncTeamPendingReplyRefresh( teamName, - pendingReplyRefreshSourceId.current!, + pendingReplyRefreshSourceKey, Boolean(messagesPanelProps.isTeamAlive) && hasPendingReplies, TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false); + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceKey, false); }; }, [ messagesPanelProps.isTeamAlive, pendingRepliesByMember, + pendingReplyRefreshSourceKey, syncTeamPendingReplyRefresh, teamName, ]); @@ -2088,10 +2119,27 @@ export const TeamDetailView = memo(function TeamDetailView({ ); const leadSessionId = data?.config.leadSessionId ?? null; + const sessionHistorySource = data?.config.sessionHistory; const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] + () => (sessionHistorySource ?? EMPTY_SESSION_HISTORY).join('|'), + [sessionHistorySource] ); + const sessionHistoryCacheRef = useRef<{ key: string; value: readonly string[] }>({ + key: '', + value: EMPTY_SESSION_HISTORY, + }); + const sessionHistory = useMemo(() => { + if (!sessionHistorySource || sessionHistorySource.length === 0) { + return EMPTY_SESSION_HISTORY; + } + const cached = sessionHistoryCacheRef.current; + if (cached.key === sessionHistoryKey) { + return cached.value; + } + const value = [...sessionHistorySource]; + sessionHistoryCacheRef.current = { key: sessionHistoryKey, value }; + return value; + }, [sessionHistoryKey, sessionHistorySource]); useEffect(() => { if (!isThisTabActive || !projectId) return; @@ -2103,8 +2151,8 @@ export const TeamDetailView = memo(function TeamDetailView({ void (async () => { try { const result = await loadTeamSessionMetadata(api, projectId, { - leadSessionId: data?.config.leadSessionId ?? null, - sessionHistory: data?.config.sessionHistory ?? [], + leadSessionId, + sessionHistory, }); if (!cancelled) { setSessions(result); @@ -2123,7 +2171,7 @@ export const TeamDetailView = memo(function TeamDetailView({ return () => { cancelled = true; }; - }, [data?.config.leadSessionId, isThisTabActive, projectId, sessionHistoryKey]); + }, [isThisTabActive, leadSessionId, projectId, sessionHistory]); // Live git branch tracking for the lead project and member worktrees const teamProjectPath = data?.config.projectPath?.trim() ?? null; @@ -2166,8 +2214,9 @@ export const TeamDetailView = memo(function TeamDetailView({ const leadBranch = leadProjectPath ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) : null; + const hasSelectedTeamData = data !== null; const membersWithLiveBranches = useMemo(() => { - if (!data) return []; + if (!hasSelectedTeamData) return []; return members.map((member) => { const memberPath = member.cwd?.trim(); @@ -2191,7 +2240,7 @@ export const TeamDetailView = memo(function TeamDetailView({ } return nextMember; }); - }, [leadBranch, members, trackedBranches]); + }, [hasSelectedTeamData, leadBranch, members, trackedBranches]); const resolvedMemberColorMap = useMemo( () => buildMemberColorMap(membersWithLiveBranches), [membersWithLiveBranches] @@ -2395,9 +2444,12 @@ export const TeamDetailView = memo(function TeamDetailView({ }); }, []); - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); + const handleCreateTaskFromMessage = useCallback( + (subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, + [openCreateTaskDialog] + ); const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { setSendDialogRecipient(message.from); @@ -2569,7 +2621,7 @@ export const TeamDetailView = memo(function TeamDetailView({ } }, - [] + [openCreateTaskDialog] ); const handleStopTeam = useCallback(async (): Promise => { diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 7ceeb7b4..f6c01d67 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -279,7 +279,9 @@ describe('KanbanTaskCard comment badge pulse', () => { unreadCommentCountMock.calls = 0; await rerenderTaskCard(root, { task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] }, - taskMap: new Map([['dep-1', { ...blockedTask, subject: 'Dependency B', status: 'done' }]]), + taskMap: new Map([ + ['dep-1', { ...blockedTask, subject: 'Dependency B', status: 'completed' }], + ]), memberColorMap, }); diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index d960bfb6..5d3eb926 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -75,19 +75,30 @@ function areResolvedMembersEquivalent( const rightMember = right[index]; if ( leftMember.name !== rightMember.name || + leftMember.agentId !== rightMember.agentId || leftMember.status !== rightMember.status || leftMember.currentTaskId !== rightMember.currentTaskId || leftMember.taskCount !== rightMember.taskCount || + leftMember.lastActiveAt !== rightMember.lastActiveAt || + leftMember.messageCount !== rightMember.messageCount || leftMember.color !== rightMember.color || leftMember.agentType !== rightMember.agentType || leftMember.role !== rightMember.role || leftMember.workflow !== rightMember.workflow || + leftMember.isolation !== rightMember.isolation || leftMember.providerId !== rightMember.providerId || + leftMember.providerBackendId !== rightMember.providerBackendId || leftMember.model !== rightMember.model || leftMember.effort !== rightMember.effort || + leftMember.selectedFastMode !== rightMember.selectedFastMode || + leftMember.resolvedFastMode !== rightMember.resolvedFastMode || + leftMember.laneId !== rightMember.laneId || + leftMember.laneKind !== rightMember.laneKind || + leftMember.laneOwnerProviderId !== rightMember.laneOwnerProviderId || leftMember.cwd !== rightMember.cwd || leftMember.gitBranch !== rightMember.gitBranch || leftMember.removedAt !== rightMember.removedAt || + !areMemberMcpPoliciesEquivalent(leftMember.mcpPolicy, rightMember.mcpPolicy) || leftMember.runtimeAdvisory?.kind !== rightMember.runtimeAdvisory?.kind || leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt || leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil || @@ -102,6 +113,22 @@ function areResolvedMembersEquivalent( return true; } +function areMemberMcpPoliciesEquivalent( + left: ResolvedTeamMember['mcpPolicy'], + right: ResolvedTeamMember['mcpPolicy'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + return ( + left.mode === right.mode && + left.scopes?.user === right.scopes?.user && + left.scopes?.project === right.scopes?.project && + left.scopes?.local === right.scopes?.local && + (left.serverNames ?? []).length === (right.serverNames ?? []).length && + (left.serverNames ?? []).every((serverName, index) => serverName === right.serverNames?.[index]) + ); +} + function areTaskStatusCountsMapsEquivalent( left: Map | undefined, right: Map | undefined @@ -553,6 +580,10 @@ function areMemberListPropsEqual( prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && prev.leadActivity === next.leadActivity && + prev.onMemberClick === next.onMemberClick && + prev.onSendMessage === next.onSendMessage && + prev.onAssignTask === next.onAssignTask && + prev.onOpenTask === next.onOpenTask && prev.onRestartMember === next.onRestartMember && prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch && prev.onRestoreMember === next.onRestoreMember && diff --git a/test/renderer/components/team/activity/activityRenderCache.test.ts b/test/renderer/components/team/activity/activityRenderCache.test.ts index 2d2e9250..76c112f3 100644 --- a/test/renderer/components/team/activity/activityRenderCache.test.ts +++ b/test/renderer/components/team/activity/activityRenderCache.test.ts @@ -17,7 +17,7 @@ describe('activityRenderCache', () => { it('builds stable task reference signatures', () => { const refs: TaskRef[] = [ { taskId: 'task-1', displayId: '#1', teamName: 'team-a' }, - { taskId: 'task-2', displayId: '#2' }, + { taskId: 'task-2', displayId: '#2', teamName: '' }, ]; expect(taskRefsCacheSignature(refs)).toBe('6:task-1|2:#1|6:team-a|6:task-2|2:#2|0:'); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index 7fd861db..4083edf9 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -21,6 +21,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ currentTask?: TeamTaskWithKanban | null; reviewTask?: TeamTaskWithKanban | null; runtimeEntry?: TeamAgentRuntimeEntry; + onOpenTask?: () => void; onRestartMember?: (memberName: string) => void; onSkipMemberForLaunch?: (memberName: string) => void; onRestoreMember?: (memberName: string) => void; @@ -34,6 +35,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ spawnLaunchState, currentTask, reviewTask, + onOpenTask, onRestartMember, onSkipMemberForLaunch, onRestoreMember, @@ -46,6 +48,17 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ currentTask ? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id) : null, + currentTask && onOpenTask + ? React.createElement( + 'button', + { + 'data-testid': `open-task-${member.name}`, + type: 'button', + onClick: onOpenTask, + }, + 'open' + ) + : null, reviewTask ? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id) : null, @@ -345,6 +358,100 @@ describe('MemberList spawn-status memoization', () => { }); }); + it('refreshes row action handlers when parent callbacks change', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const firstOpenTask = vi.fn(); + const secondOpenTask = vi.fn(); + const task = activeTask(); + const activeMember = { ...member, currentTaskId: task.id }; + const taskMap = new Map([[task.id, task]]); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members: [activeMember], + taskMap, + isTeamAlive: true, + onOpenTask: firstOpenTask, + }) + ); + await Promise.resolve(); + }); + + host.querySelector('[data-testid="open-task-bob"]')?.click(); + expect(firstOpenTask).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members: [activeMember], + taskMap, + isTeamAlive: true, + onOpenTask: secondOpenTask, + }) + ); + await Promise.resolve(); + }); + + host.querySelector('[data-testid="open-task-bob"]')?.click(); + expect(firstOpenTask).toHaveBeenCalledTimes(1); + expect(secondOpenTask).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('rerenders cards when visible member configuration changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members: [member], + isTeamAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(memberCardRenderSpy).toHaveBeenCalledTimes(1); + memberCardRenderSpy.mockClear(); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members: [ + { + ...member, + isolation: 'worktree', + providerBackendId: 'opencode-cli', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + mcpPolicy: { mode: 'appOnly' }, + }, + ], + isTeamAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(memberCardRenderSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('rerenders cards when only the hard failure reason changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');