fix: restore dev validation after team page merge
This commit is contained in:
parent
fde59c0a4a
commit
dc1d310df8
5 changed files with 221 additions and 29 deletions
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -177,6 +178,7 @@ import type { SessionInjection } from './session-injection-types';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type {
|
import type {
|
||||||
|
KanbanColumnId,
|
||||||
KanbanTaskState,
|
KanbanTaskState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
|
@ -292,6 +294,7 @@ interface CreateTaskDialogState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
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 MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
|
||||||
const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200;
|
const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200;
|
||||||
|
|
||||||
|
|
@ -362,18 +365,30 @@ function areResolvedMembersEqual(
|
||||||
const nextMember = next[i];
|
const nextMember = next[i];
|
||||||
if (
|
if (
|
||||||
prevMember.name !== nextMember.name ||
|
prevMember.name !== nextMember.name ||
|
||||||
|
prevMember.agentId !== nextMember.agentId ||
|
||||||
prevMember.status !== nextMember.status ||
|
prevMember.status !== nextMember.status ||
|
||||||
prevMember.currentTaskId !== nextMember.currentTaskId ||
|
prevMember.currentTaskId !== nextMember.currentTaskId ||
|
||||||
|
prevMember.taskCount !== nextMember.taskCount ||
|
||||||
|
prevMember.lastActiveAt !== nextMember.lastActiveAt ||
|
||||||
|
prevMember.messageCount !== nextMember.messageCount ||
|
||||||
prevMember.color !== nextMember.color ||
|
prevMember.color !== nextMember.color ||
|
||||||
prevMember.agentType !== nextMember.agentType ||
|
prevMember.agentType !== nextMember.agentType ||
|
||||||
prevMember.role !== nextMember.role ||
|
prevMember.role !== nextMember.role ||
|
||||||
prevMember.workflow !== nextMember.workflow ||
|
prevMember.workflow !== nextMember.workflow ||
|
||||||
|
prevMember.isolation !== nextMember.isolation ||
|
||||||
prevMember.providerId !== nextMember.providerId ||
|
prevMember.providerId !== nextMember.providerId ||
|
||||||
|
prevMember.providerBackendId !== nextMember.providerBackendId ||
|
||||||
prevMember.model !== nextMember.model ||
|
prevMember.model !== nextMember.model ||
|
||||||
prevMember.effort !== nextMember.effort ||
|
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.cwd !== nextMember.cwd ||
|
||||||
prevMember.gitBranch !== nextMember.gitBranch ||
|
prevMember.gitBranch !== nextMember.gitBranch ||
|
||||||
prevMember.removedAt !== nextMember.removedAt ||
|
prevMember.removedAt !== nextMember.removedAt ||
|
||||||
|
!areMemberMcpPoliciesEqual(prevMember.mcpPolicy, nextMember.mcpPolicy) ||
|
||||||
prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind ||
|
prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind ||
|
||||||
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
|
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
|
||||||
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
|
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
|
||||||
|
|
@ -388,6 +403,22 @@ function areResolvedMembersEqual(
|
||||||
return true;
|
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(
|
function useStableActiveMembers(
|
||||||
members: readonly ResolvedTeamMember[] | undefined
|
members: readonly ResolvedTeamMember[] | undefined
|
||||||
): ResolvedTeamMember[] {
|
): ResolvedTeamMember[] {
|
||||||
|
|
@ -935,7 +966,6 @@ interface LeadLoadBridgeProps {
|
||||||
|
|
||||||
const pendingRepliesCacheByTeam = new Map<string, Record<string, number>>();
|
const pendingRepliesCacheByTeam = new Map<string, Record<string, number>>();
|
||||||
const pendingRepliesListenersByTeam = new Map<string, Set<() => void>>();
|
const pendingRepliesListenersByTeam = new Map<string, Set<() => void>>();
|
||||||
let pendingReplyRefreshSourceSequence = 0;
|
|
||||||
|
|
||||||
function getPendingRepliesSnapshot(teamName: string): Record<string, number> {
|
function getPendingRepliesSnapshot(teamName: string): Record<string, number> {
|
||||||
let snapshot = pendingRepliesCacheByTeam.get(teamName);
|
let snapshot = pendingRepliesCacheByTeam.get(teamName);
|
||||||
|
|
@ -1441,11 +1471,8 @@ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({
|
||||||
...props
|
...props
|
||||||
}: TeamMessagesPanelBridgeProps): React.JSX.Element {
|
}: TeamMessagesPanelBridgeProps): React.JSX.Element {
|
||||||
const pendingRepliesByMember = useTeamPendingReplies(teamName);
|
const pendingRepliesByMember = useTeamPendingReplies(teamName);
|
||||||
const pendingReplyRefreshSourceId = useRef<string | null>(null);
|
const pendingReplyRefreshSourceId = useId();
|
||||||
if (pendingReplyRefreshSourceId.current === null) {
|
const pendingReplyRefreshSourceKey = `team-messages:${pendingReplyRefreshSourceId}`;
|
||||||
pendingReplyRefreshSourceSequence += 1;
|
|
||||||
pendingReplyRefreshSourceId.current = `team-messages:${pendingReplyRefreshSourceSequence}`;
|
|
||||||
}
|
|
||||||
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
|
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
|
@ -1458,15 +1485,21 @@ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({
|
||||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||||
syncTeamPendingReplyRefresh(
|
syncTeamPendingReplyRefresh(
|
||||||
teamName,
|
teamName,
|
||||||
pendingReplyRefreshSourceId.current!,
|
pendingReplyRefreshSourceKey,
|
||||||
Boolean(isTeamAlive) && hasPendingReplies,
|
Boolean(isTeamAlive) && hasPendingReplies,
|
||||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false);
|
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceKey, false);
|
||||||
};
|
};
|
||||||
}, [isTeamAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
|
}, [
|
||||||
|
isTeamAlive,
|
||||||
|
pendingRepliesByMember,
|
||||||
|
pendingReplyRefreshSourceKey,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
const handlePendingReplyChange = useCallback(
|
const handlePendingReplyChange = useCallback(
|
||||||
(updater: PendingRepliesUpdater) => {
|
(updater: PendingRepliesUpdater) => {
|
||||||
|
|
@ -1494,11 +1527,8 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
|
||||||
}: TeamSidebarRailBridgeProps): React.JSX.Element {
|
}: TeamSidebarRailBridgeProps): React.JSX.Element {
|
||||||
const teamName = messagesPanelProps.teamName;
|
const teamName = messagesPanelProps.teamName;
|
||||||
const pendingRepliesByMember = useTeamPendingReplies(teamName);
|
const pendingRepliesByMember = useTeamPendingReplies(teamName);
|
||||||
const pendingReplyRefreshSourceId = useRef<string | null>(null);
|
const pendingReplyRefreshSourceId = useId();
|
||||||
if (pendingReplyRefreshSourceId.current === null) {
|
const pendingReplyRefreshSourceKey = `team-sidebar:${pendingReplyRefreshSourceId}`;
|
||||||
pendingReplyRefreshSourceSequence += 1;
|
|
||||||
pendingReplyRefreshSourceId.current = `team-sidebar:${pendingReplyRefreshSourceSequence}`;
|
|
||||||
}
|
|
||||||
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
|
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
|
@ -1510,17 +1540,18 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
|
||||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||||
syncTeamPendingReplyRefresh(
|
syncTeamPendingReplyRefresh(
|
||||||
teamName,
|
teamName,
|
||||||
pendingReplyRefreshSourceId.current!,
|
pendingReplyRefreshSourceKey,
|
||||||
Boolean(messagesPanelProps.isTeamAlive) && hasPendingReplies,
|
Boolean(messagesPanelProps.isTeamAlive) && hasPendingReplies,
|
||||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false);
|
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceKey, false);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
messagesPanelProps.isTeamAlive,
|
messagesPanelProps.isTeamAlive,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
|
pendingReplyRefreshSourceKey,
|
||||||
syncTeamPendingReplyRefresh,
|
syncTeamPendingReplyRefresh,
|
||||||
teamName,
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
@ -2088,10 +2119,27 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
);
|
);
|
||||||
|
|
||||||
const leadSessionId = data?.config.leadSessionId ?? null;
|
const leadSessionId = data?.config.leadSessionId ?? null;
|
||||||
|
const sessionHistorySource = data?.config.sessionHistory;
|
||||||
const sessionHistoryKey = useMemo(
|
const sessionHistoryKey = useMemo(
|
||||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
() => (sessionHistorySource ?? EMPTY_SESSION_HISTORY).join('|'),
|
||||||
[data?.config.sessionHistory]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!isThisTabActive || !projectId) return;
|
if (!isThisTabActive || !projectId) return;
|
||||||
|
|
@ -2103,8 +2151,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await loadTeamSessionMetadata(api, projectId, {
|
const result = await loadTeamSessionMetadata(api, projectId, {
|
||||||
leadSessionId: data?.config.leadSessionId ?? null,
|
leadSessionId,
|
||||||
sessionHistory: data?.config.sessionHistory ?? [],
|
sessionHistory,
|
||||||
});
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSessions(result);
|
setSessions(result);
|
||||||
|
|
@ -2123,7 +2171,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [data?.config.leadSessionId, isThisTabActive, projectId, sessionHistoryKey]);
|
}, [isThisTabActive, leadSessionId, projectId, sessionHistory]);
|
||||||
|
|
||||||
// Live git branch tracking for the lead project and member worktrees
|
// Live git branch tracking for the lead project and member worktrees
|
||||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
|
|
@ -2166,8 +2214,9 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
const leadBranch = leadProjectPath
|
const leadBranch = leadProjectPath
|
||||||
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const hasSelectedTeamData = data !== null;
|
||||||
const membersWithLiveBranches = useMemo(() => {
|
const membersWithLiveBranches = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!hasSelectedTeamData) return [];
|
||||||
|
|
||||||
return members.map((member) => {
|
return members.map((member) => {
|
||||||
const memberPath = member.cwd?.trim();
|
const memberPath = member.cwd?.trim();
|
||||||
|
|
@ -2191,7 +2240,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
}
|
}
|
||||||
return nextMember;
|
return nextMember;
|
||||||
});
|
});
|
||||||
}, [leadBranch, members, trackedBranches]);
|
}, [hasSelectedTeamData, leadBranch, members, trackedBranches]);
|
||||||
const resolvedMemberColorMap = useMemo(
|
const resolvedMemberColorMap = useMemo(
|
||||||
() => buildMemberColorMap(membersWithLiveBranches),
|
() => buildMemberColorMap(membersWithLiveBranches),
|
||||||
[membersWithLiveBranches]
|
[membersWithLiveBranches]
|
||||||
|
|
@ -2395,9 +2444,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => {
|
const handleCreateTaskFromMessage = useCallback(
|
||||||
openCreateTaskDialog(subject, description);
|
(subject: string, description: string) => {
|
||||||
}, []);
|
openCreateTaskDialog(subject, description);
|
||||||
|
},
|
||||||
|
[openCreateTaskDialog]
|
||||||
|
);
|
||||||
|
|
||||||
const handleReplyToMessage = useCallback((message: { from: string; text: string }) => {
|
const handleReplyToMessage = useCallback((message: { from: string; text: string }) => {
|
||||||
setSendDialogRecipient(message.from);
|
setSendDialogRecipient(message.from);
|
||||||
|
|
@ -2569,7 +2621,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[]
|
[openCreateTaskDialog]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopTeam = useCallback(async (): Promise<void> => {
|
const handleStopTeam = useCallback(async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,9 @@ describe('KanbanTaskCard comment badge pulse', () => {
|
||||||
unreadCommentCountMock.calls = 0;
|
unreadCommentCountMock.calls = 0;
|
||||||
await rerenderTaskCard(root, {
|
await rerenderTaskCard(root, {
|
||||||
task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] },
|
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,
|
memberColorMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,19 +75,30 @@ function areResolvedMembersEquivalent(
|
||||||
const rightMember = right[index];
|
const rightMember = right[index];
|
||||||
if (
|
if (
|
||||||
leftMember.name !== rightMember.name ||
|
leftMember.name !== rightMember.name ||
|
||||||
|
leftMember.agentId !== rightMember.agentId ||
|
||||||
leftMember.status !== rightMember.status ||
|
leftMember.status !== rightMember.status ||
|
||||||
leftMember.currentTaskId !== rightMember.currentTaskId ||
|
leftMember.currentTaskId !== rightMember.currentTaskId ||
|
||||||
leftMember.taskCount !== rightMember.taskCount ||
|
leftMember.taskCount !== rightMember.taskCount ||
|
||||||
|
leftMember.lastActiveAt !== rightMember.lastActiveAt ||
|
||||||
|
leftMember.messageCount !== rightMember.messageCount ||
|
||||||
leftMember.color !== rightMember.color ||
|
leftMember.color !== rightMember.color ||
|
||||||
leftMember.agentType !== rightMember.agentType ||
|
leftMember.agentType !== rightMember.agentType ||
|
||||||
leftMember.role !== rightMember.role ||
|
leftMember.role !== rightMember.role ||
|
||||||
leftMember.workflow !== rightMember.workflow ||
|
leftMember.workflow !== rightMember.workflow ||
|
||||||
|
leftMember.isolation !== rightMember.isolation ||
|
||||||
leftMember.providerId !== rightMember.providerId ||
|
leftMember.providerId !== rightMember.providerId ||
|
||||||
|
leftMember.providerBackendId !== rightMember.providerBackendId ||
|
||||||
leftMember.model !== rightMember.model ||
|
leftMember.model !== rightMember.model ||
|
||||||
leftMember.effort !== rightMember.effort ||
|
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.cwd !== rightMember.cwd ||
|
||||||
leftMember.gitBranch !== rightMember.gitBranch ||
|
leftMember.gitBranch !== rightMember.gitBranch ||
|
||||||
leftMember.removedAt !== rightMember.removedAt ||
|
leftMember.removedAt !== rightMember.removedAt ||
|
||||||
|
!areMemberMcpPoliciesEquivalent(leftMember.mcpPolicy, rightMember.mcpPolicy) ||
|
||||||
leftMember.runtimeAdvisory?.kind !== rightMember.runtimeAdvisory?.kind ||
|
leftMember.runtimeAdvisory?.kind !== rightMember.runtimeAdvisory?.kind ||
|
||||||
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
|
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
|
||||||
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
|
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
|
||||||
|
|
@ -102,6 +113,22 @@ function areResolvedMembersEquivalent(
|
||||||
return true;
|
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(
|
function areTaskStatusCountsMapsEquivalent(
|
||||||
left: Map<string, TaskStatusCounts> | undefined,
|
left: Map<string, TaskStatusCounts> | undefined,
|
||||||
right: Map<string, TaskStatusCounts> | undefined
|
right: Map<string, TaskStatusCounts> | undefined
|
||||||
|
|
@ -553,6 +580,10 @@ function areMemberListPropsEqual(
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
prev.leadActivity === next.leadActivity &&
|
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.onRestartMember === next.onRestartMember &&
|
||||||
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||||
prev.onRestoreMember === next.onRestoreMember &&
|
prev.onRestoreMember === next.onRestoreMember &&
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ describe('activityRenderCache', () => {
|
||||||
it('builds stable task reference signatures', () => {
|
it('builds stable task reference signatures', () => {
|
||||||
const refs: TaskRef[] = [
|
const refs: TaskRef[] = [
|
||||||
{ taskId: 'task-1', displayId: '#1', teamName: 'team-a' },
|
{ 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:');
|
expect(taskRefsCacheSignature(refs)).toBe('6:task-1|2:#1|6:team-a|6:task-2|2:#2|0:');
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
currentTask?: TeamTaskWithKanban | null;
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
reviewTask?: TeamTaskWithKanban | null;
|
reviewTask?: TeamTaskWithKanban | null;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
onOpenTask?: () => void;
|
||||||
onRestartMember?: (memberName: string) => void;
|
onRestartMember?: (memberName: string) => void;
|
||||||
onSkipMemberForLaunch?: (memberName: string) => void;
|
onSkipMemberForLaunch?: (memberName: string) => void;
|
||||||
onRestoreMember?: (memberName: string) => void;
|
onRestoreMember?: (memberName: string) => void;
|
||||||
|
|
@ -34,6 +35,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
currentTask,
|
currentTask,
|
||||||
reviewTask,
|
reviewTask,
|
||||||
|
onOpenTask,
|
||||||
onRestartMember,
|
onRestartMember,
|
||||||
onSkipMemberForLaunch,
|
onSkipMemberForLaunch,
|
||||||
onRestoreMember,
|
onRestoreMember,
|
||||||
|
|
@ -46,6 +48,17 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
currentTask
|
currentTask
|
||||||
? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id)
|
? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id)
|
||||||
: null,
|
: null,
|
||||||
|
currentTask && onOpenTask
|
||||||
|
? React.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
'data-testid': `open-task-${member.name}`,
|
||||||
|
type: 'button',
|
||||||
|
onClick: onOpenTask,
|
||||||
|
},
|
||||||
|
'open'
|
||||||
|
)
|
||||||
|
: null,
|
||||||
reviewTask
|
reviewTask
|
||||||
? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id)
|
? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id)
|
||||||
: null,
|
: 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 () => {
|
it('rerenders cards when only the hard failure reason changes', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue