fix: restore dev validation after team page merge

This commit is contained in:
777genius 2026-05-31 15:46:10 +03:00
parent fde59c0a4a
commit dc1d310df8
5 changed files with 221 additions and 29 deletions

View file

@ -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<string, Record<string, number>>();
const pendingRepliesListenersByTeam = new Map<string, Set<() => void>>();
let pendingReplyRefreshSourceSequence = 0;
function getPendingRepliesSnapshot(teamName: string): Record<string, number> {
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<string | null>(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<string | null>(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<void> => {

View file

@ -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,
});

View file

@ -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<string, TaskStatusCounts> | undefined,
right: Map<string, TaskStatusCounts> | 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 &&

View file

@ -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:');

View file

@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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');