fix(team): harden refresh races and loading state

This commit is contained in:
777genius 2026-04-17 23:03:58 +03:00
parent a5c79518fb
commit 351244ffdb
19 changed files with 2141 additions and 414 deletions

View file

@ -198,15 +198,14 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void {
function noteHeavyTeamDataWorkerFallback(operation: string): void {
if (!app.isPackaged) {
return;
}
logger.error(
`[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path`
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
);
throw new Error('TEAM_DATA_WORKER_UNAVAILABLE');
}
async function getDurableLeadTeammateRoster(
@ -749,11 +748,11 @@ async function handleGetData(
logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
);
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} else {
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} catch (error) {
@ -1700,7 +1699,7 @@ async function handleGetMessagesPage(
);
}
}
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage');
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
scanTeamMessageNotifications(
page.messages,
@ -1734,7 +1733,7 @@ async function handleGetMemberActivityMeta(
);
}
}
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta');
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
});
}

View file

@ -702,11 +702,6 @@ interface ProvisioningRun {
memberSpawnToolUseIds: Map<string, string>;
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
/**
* Per-member exact processed lead-inbox messageIds for the current live run.
* This owns live-path correctness and protects against out-of-order inserts.
*/
memberSpawnProcessedLeadInboxMessageIdsByMember: Map<string, Set<string>>;
/** Highest accepted deterministic bootstrap event sequence for this run. */
lastDeterministicBootstrapSeq: number;
/** Throttles config/inbox audit work triggered by frequent status polling. */
@ -842,19 +837,6 @@ function maxMemberSpawnInboxCursor(
return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right;
}
function getOrCreateMemberSpawnProcessedMessageIds(
run: ProvisioningRun,
memberName: string
): Set<string> {
const existing = run.memberSpawnProcessedLeadInboxMessageIdsByMember.get(memberName);
if (existing) {
return existing;
}
const created = new Set<string>();
run.memberSpawnProcessedLeadInboxMessageIdsByMember.set(memberName, created);
return created;
}
function isMemberSpawnHeartbeatTimestampNewer(
previous: string | undefined,
incoming: string | undefined
@ -2965,42 +2947,28 @@ export class TeamProvisioningService {
}
for (const [memberName, messages] of messagesByMember.entries()) {
const processedMessageIds = getOrCreateMemberSpawnProcessedMessageIds(run, memberName);
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
const newlyProcessedMessageIds: string[] = [];
let nextCursor = currentCursor;
for (const message of messages) {
if (processedMessageIds.has(message.messageId)) {
continue;
}
const messageCursor = toMemberSpawnInboxCursor(message);
const shouldApplySignal =
messageCursor == null ||
currentCursor == null ||
compareMemberSpawnInboxCursor(messageCursor, currentCursor) > 0;
if (shouldApplySignal) {
this.applyLeadInboxSpawnSignal(run, memberName, message);
if (messageCursor) {
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
const effectiveCursor = nextCursor ?? currentCursor;
if (messageCursor && effectiveCursor) {
if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) {
continue;
}
}
// Mark late out-of-order signals as seen so they cannot replay forever, but only
// let strictly newer cursors mutate the already-advanced live member state.
newlyProcessedMessageIds.push(message.messageId);
this.applyLeadInboxSpawnSignal(run, memberName, message);
if (messageCursor) {
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
}
}
if (newlyProcessedMessageIds.length === 0) {
continue;
}
for (const messageId of newlyProcessedMessageIds) {
processedMessageIds.add(messageId);
}
if (nextCursor) {
if (
nextCursor &&
(currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0)
) {
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
}
}
@ -5127,7 +5095,6 @@ export class TeamProvisioningService {
),
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0,
@ -5708,7 +5675,6 @@ export class TeamProvisioningService {
),
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0,

View file

@ -1,4 +1,14 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
lazy,
memo,
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { api } from '@renderer/api';
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
@ -1308,6 +1318,7 @@ export const TeamDetailView = ({
);
const leadSessionId = data?.config.leadSessionId ?? null;
const pendingReplyRefreshSourceId = useId();
const sessionHistoryKey = useMemo(
() => (data?.config.sessionHistory ?? []).join('|'),
[data?.config.sessionHistory]
@ -1320,14 +1331,21 @@ export const TeamDetailView = ({
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
pendingReplyRefreshSourceId,
Boolean(data?.isAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
syncTeamPendingReplyRefresh(teamName, false);
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
};
}, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
}, [
data?.isAlive,
pendingRepliesByMember,
pendingReplyRefreshSourceId,
syncTeamPendingReplyRefresh,
teamName,
]);
useEffect(() => {
if (!projectId) return;

View file

@ -54,7 +54,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
@ -125,14 +127,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
function buildPrepareModelCacheKey(
cwd: string,
providerId: TeamProviderId,
backendSummary: string | null | undefined
): string {
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
}
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
@ -644,7 +638,12 @@ export const CreateTeamDialog = ({
return Array.from(next);
})();
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext,
});
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
@ -714,7 +713,7 @@ export const CreateTeamDialog = ({
}
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
plan.prepResult.modelResultsById
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
);
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,

View file

@ -72,8 +72,10 @@ import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import { ProjectPathSelector } from './ProjectPathSelector';
import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
@ -110,14 +112,6 @@ import type {
UpdateSchedulePatch,
} from '@shared/types';
function buildPrepareModelCacheKey(
cwd: string,
providerId: TeamProviderId,
backendSummary: string | null | undefined
): string {
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
}
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
@ -933,7 +927,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const providerPlans = selectedMemberProviders.map((providerId) => {
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext,
});
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
@ -1001,7 +1000,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} else if (plan.prepResult.status === 'notes') {
anyNotes = true;
}
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
);
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,
backendSummary: plan.backendSummary,

View file

@ -0,0 +1,20 @@
import type { TeamProviderId } from '@shared/types';
export function buildProviderPrepareModelCacheKey({
cwd,
providerId,
backendSummary,
limitContext,
}: {
cwd: string;
providerId: TeamProviderId;
backendSummary: string | null | undefined;
limitContext: boolean;
}): string {
return [
cwd,
providerId,
backendSummary ?? '',
limitContext ? 'limit-context:on' : 'limit-context:off',
].join('::');
}

View file

@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult {
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
}
export function buildReusableProviderPrepareModelResults(
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
): Record<string, ProviderPrepareDiagnosticsModelResult> {
return Object.fromEntries(
Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes')
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
return null;
}
function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] {
const escapedModelId = escapeRegExp(modelId);
const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i');
return [...(result.details ?? []), ...(result.warnings ?? []), result.message]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean)
.filter((entry) => scopedPattern.test(entry));
}
function getScopedModelReason(modelId: string, entries: string[]): string | null {
for (const entry of entries) {
const stripped = stripSelectedModelPrefix(modelId, entry);
if (!stripped) {
continue;
}
const normalized = normalizeModelReason(stripped);
if (normalized) {
return normalized;
}
}
return null;
}
function buildModelFailureLine(
providerId: TeamProviderId,
modelId: string,
@ -201,6 +232,72 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
return [...(result.details ?? []), ...(result.warnings ?? [])];
}
function resolveModelResultFromBatch(
providerId: TeamProviderId,
modelId: string,
result: TeamProvisioningPrepareResult,
isOnlyModel: boolean
): ProviderPrepareDiagnosticsModelResult {
const modelScopedEntries = getModelScopedEntries(modelId, result);
const normalizedReason =
getScopedModelReason(modelId, modelScopedEntries) ??
(isOnlyModel ? normalizeModelReason(result.message) : null);
const hasVerifiedLine = modelScopedEntries.some((entry) =>
/selected model .* verified for launch\./i.test(entry)
);
if (hasVerifiedLine) {
return {
status: 'ready',
line: buildModelSuccessLine(providerId, modelId),
warningLine: null,
};
}
const hasUnavailableLine = modelScopedEntries.some((entry) =>
/selected model .* is unavailable\./i.test(entry)
);
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
return {
status: 'failed',
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason),
warningLine: null,
};
}
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
/selected model .* could not be verified\./i.test(entry)
);
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) {
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason);
return {
status: 'notes',
line,
warningLine: line,
};
}
if (result.ready) {
return {
status: 'ready',
line: buildModelSuccessLine(providerId, modelId),
warningLine: null,
};
}
const line = buildModelFailureLine(
providerId,
modelId,
'check failed',
normalizedReason ?? 'Model verification failed'
);
return {
status: 'notes',
line,
warningLine: line,
};
}
export async function runProviderPrepareDiagnostics({
cwd,
providerId,
@ -289,71 +386,55 @@ export async function runProviderPrepareDiagnostics({
emitProgress();
await Promise.all(
orderedModelIds
.filter((modelId) => !modelResultsById.has(modelId))
.map(async (modelId) => {
try {
const modelResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
[modelId],
limitContext
);
if (!modelResult.ready) {
hasFailure = true;
const line = buildModelFailureLine(
providerId,
modelId,
'unavailable',
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'failed',
line,
warningLine: null,
});
} else if ((modelResult.warnings?.length ?? 0) > 0) {
hasNotes = true;
const reason = getResultReason(modelId, modelResult);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} else {
const line = buildModelSuccessLine(providerId, modelId);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'ready',
line,
warningLine: null,
});
}
} catch (error) {
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
if (uncachedModelIds.length > 0) {
try {
const batchedModelResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
uncachedModelIds,
limitContext
);
for (const modelId of uncachedModelIds) {
const resolvedResult = resolveModelResultFromBatch(
providerId,
modelId,
batchedModelResult,
uncachedModelIds.length === 1
);
modelLines.set(modelId, resolvedResult.line);
modelResultsById.set(modelId, resolvedResult);
if (resolvedResult.status === 'failed') {
hasFailure = true;
} else if (resolvedResult.status === 'notes') {
hasNotes = true;
const reason = normalizeModelReason(
error instanceof Error ? error.message.trim() : String(error).trim()
);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} finally {
completedCount += 1;
emitProgress();
}
})
);
if (resolvedResult.warningLine) {
modelWarnings.push(resolvedResult.warningLine);
}
}
} catch (error) {
hasNotes = true;
const reason = normalizeModelReason(
error instanceof Error ? error.message.trim() : String(error).trim()
);
for (const modelId of uncachedModelIds) {
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
}
} finally {
completedCount += uncachedModelIds.length;
emitProgress();
}
}
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
const selectedModelResultsById = Object.fromEntries(

View file

@ -4,10 +4,13 @@ import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
import { buildMemberActivityEntries } from './memberActivityEntries';
import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import { MemberLogsTab } from './MemberLogsTab';
@ -71,7 +74,21 @@ export const MemberDetailDialog = ({
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
);
const memberActivityCount = member?.messageCount ?? 0;
const memberMessages = useStore((state) =>
selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null)
);
const memberActivityCount = useMemo(() => {
if (!member) {
return 0;
}
return buildMemberActivityEntries({
teamName,
memberName: member.name,
members,
tasks,
messages: memberMessages,
}).length;
}, [member, memberMessages, members, tasks, teamName]);
const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length,

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
@ -11,11 +10,11 @@ import { Button } from '@renderer/components/ui/button';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isLeadMember } from '@shared/utils/leadDetection';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberActivityEntries } from './memberActivityEntries';
import type { MemberActivityFilter } from './memberDetailTypes';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -56,13 +55,6 @@ export const MemberMessagesTab = ({
}))
);
const { readSet } = useTeamMessagesRead(teamName);
const leadId = `lead:${teamName}`;
const leadName = useMemo(
() => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`,
[members, teamName]
);
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
@ -80,45 +72,34 @@ export const MemberMessagesTab = ({
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
const hasMore = messagesState?.hasMore ?? false;
const filteredMessages = useMemo(
() =>
filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
}),
[messages]
);
const activityEntries = useMemo(() => {
const entriesByOwner = buildInlineActivityEntries({
data: {
members,
tasks,
messages: filteredMessages,
},
return buildMemberActivityEntries({
teamName,
leadId,
leadName,
ownerNodeIds,
memberName,
members,
tasks,
messages,
});
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
}, [memberName, members, messages, tasks, teamName]);
const visibleActivityEntries = useMemo(
() => activityEntries.slice(0, MAX_MESSAGES),
[activityEntries]
);
const displayEntries = useMemo(() => {
switch (activityFilter) {
case 'messages':
return activityEntries.filter(
return visibleActivityEntries.filter(
(entry) => entry.message.messageKind !== 'task_comment_notification'
);
case 'comments':
return activityEntries.filter(
return visibleActivityEntries.filter(
(entry) => entry.message.messageKind === 'task_comment_notification'
);
default:
return activityEntries;
return visibleActivityEntries;
}
}, [activityEntries, activityFilter]);
}, [activityFilter, visibleActivityEntries]);
const expandedItemsByKey = useMemo(() => {
const items = new Map<string, TimelineItem>();
@ -156,9 +137,10 @@ export const MemberMessagesTab = ({
? hasMore
? 'No loaded messages for this member yet'
: 'No messages with this member'
: 'No activity with this member';
const canLoadOlderMessages =
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
: hasMore
? 'No loaded activity for this member yet'
: 'No activity with this member';
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
return (
<div className="space-y-3">

View file

@ -0,0 +1,42 @@
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { isLeadMember } from '@shared/utils/leadDetection';
import type { InlineActivityEntry } from '@features/agent-graph/core/domain/buildInlineActivityEntries';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
export function buildMemberActivityEntries({
teamName,
memberName,
members,
tasks,
messages,
}: {
teamName: string;
memberName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
}): InlineActivityEntry[] {
const filteredMessages = filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
const leadId = `lead:${teamName}`;
const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
const ownerNodeIds = new Set([leadId, ownerNodeId]);
const entriesByOwner = buildInlineActivityEntries({
data: {
members,
tasks,
messages: filteredMessages,
},
teamName,
leadId,
leadName,
ownerNodeIds,
});
return entriesByOwner.get(ownerNodeId) ?? [];
}

View file

@ -79,7 +79,7 @@ const TEAM_REFRESH_BURST_WARN_COUNT = 5;
const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
const inFlightRefreshTeamDataCalls = new Set<string>();
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
const pendingFreshTeamDataRefreshes = new Set<string>();
const inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
@ -91,8 +91,9 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set<string>();
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const activeTeamPendingReplyWaits = new Set<string>();
const activeTeamPendingReplyWaitSourceIdsByTeam = new Map<string, Set<string>>();
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
const teamLocalStateEpochByTeam = new Map<string, number>();
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
let pendingFreshGlobalTasksRefresh = false;
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
@ -119,7 +120,7 @@ interface TeamGraphLayoutSessionState {
export function isTeamDataRefreshPending(teamName: string): boolean {
return (
inFlightTeamDataRequests.has(teamName) ||
inFlightRefreshTeamDataCalls.has(teamName) ||
(inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 ||
pendingFreshTeamDataRefreshes.has(teamName)
);
}
@ -129,11 +130,15 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und
}
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
return activeTeamPendingReplyWaits.has(teamName);
return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0;
}
export function getActiveTeamPendingReplyWaits(): Set<string> {
return new Set(activeTeamPendingReplyWaits);
return new Set(
Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries())
.filter(([, sourceIds]) => sourceIds.size > 0)
.map(([teamName]) => teamName)
);
}
export function __resetTeamSliceModuleStateForTests(): void {
@ -150,8 +155,9 @@ export function __resetTeamSliceModuleStateForTests(): void {
clearTimeout(timer);
}
pendingTeamPendingReplyRefreshTimers.clear();
activeTeamPendingReplyWaits.clear();
activeTeamPendingReplyWaitSourceIdsByTeam.clear();
lastResolvedTeamDataRefreshAtByTeam.clear();
teamLocalStateEpochByTeam.clear();
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
teamRefreshBurstDiagnostics.clear();
memberSpawnUiEqualLastWarnAtByTeam.clear();
@ -161,6 +167,274 @@ export function __resetTeamSliceModuleStateForTests(): void {
memberMessagesSelectorCache.clear();
}
function clearTeamScopedSelectorCaches(teamName: string): void {
resolvedMembersSelectorCache.delete(teamName);
mergedMessagesSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCache.delete(key);
}
}
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCache.delete(key);
}
}
}
function clearTeamScopedTransientState(teamName: string): void {
inFlightTeamDataRequests.delete(teamName);
inFlightRefreshTeamDataCalls.delete(teamName);
pendingFreshTeamDataRefreshes.delete(teamName);
inFlightTeamMessagesHeadRequests.delete(teamName);
inFlightTeamMessagesOlderRequests.delete(teamName);
queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName);
pendingFreshTeamMessagesHeadRefreshes.delete(teamName);
inFlightTeamMemberActivityMetaRequests.delete(teamName);
pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName);
lastResolvedTeamDataRefreshAtByTeam.delete(teamName);
memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName);
teamRefreshBurstDiagnostics.delete(teamName);
memberSpawnUiEqualLastWarnAtByTeam.delete(teamName);
clearTeamScopedSelectorCaches(teamName);
}
function collectTeamScopedVisibleLoadingResets(
state: Pick<
TeamSlice,
'teamMessagesByName' | 'selectedTeamName' | 'selectedTeamLoading' | 'selectedTeamError'
>,
teamName: string
): Partial<TeamSlice> {
const nextTeamMessagesEntry = state.teamMessagesByName[teamName];
const nextTeamMessagesByName =
nextTeamMessagesEntry &&
(nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder)
? {
...state.teamMessagesByName,
[teamName]: {
...nextTeamMessagesEntry,
loadingHead: false,
loadingOlder: false,
},
}
: null;
const shouldResetSelectedSurface =
state.selectedTeamName === teamName &&
(state.selectedTeamLoading || state.selectedTeamError != null);
return {
...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}),
...(shouldResetSelectedSurface
? {
selectedTeamLoading: false,
selectedTeamError: null,
}
: {}),
};
}
function omitTeamKey<T>(record: Record<string, T>, teamName: string): Record<string, T> | null {
if (!(teamName in record)) {
return null;
}
const next = { ...record };
delete next[teamName];
return next;
}
function collectTeamScopedStateRemovals(
state: Pick<
TeamSlice,
| 'provisioningRuns'
| 'teamDataCacheByName'
| 'teamMessagesByName'
| 'memberActivityMetaByTeam'
| 'provisioningSnapshotByTeam'
| 'currentProvisioningRunIdByTeam'
| 'currentRuntimeRunIdByTeam'
| 'provisioningStartedAtFloorByTeam'
| 'leadActivityByTeam'
| 'leadContextByTeam'
| 'activeToolsByTeam'
| 'finishedVisibleByTeam'
| 'toolHistoryByTeam'
| 'memberSpawnStatusesByTeam'
| 'memberSpawnSnapshotsByTeam'
| 'provisioningErrorByTeam'
>,
teamName: string
): Partial<TeamSlice> {
const nextProvisioningRuns = Object.fromEntries(
Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName)
) as Record<string, TeamProvisioningProgress>;
const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName);
const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName);
const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName);
const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName);
const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName);
const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName);
const nextProvisioningStartedAtFloor = omitTeamKey(
state.provisioningStartedAtFloorByTeam,
teamName
);
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName);
const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName);
const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName);
return {
...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length
? { provisioningRuns: nextProvisioningRuns }
: {}),
...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}),
...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}),
...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}),
...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}),
...(nextCurrentProvisioningRunId
? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId }
: {}),
...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}),
...(nextProvisioningStartedAtFloor
? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor }
: {}),
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}),
...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}),
...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}),
};
}
function buildTeamScopedProgressTombstones(
state: Pick<
TeamSlice,
| 'currentProvisioningRunIdByTeam'
| 'currentRuntimeRunIdByTeam'
| 'ignoredProvisioningRunIds'
| 'ignoredRuntimeRunIds'
| 'provisioningStartedAtFloorByTeam'
>,
teamName: string,
floor: string
): Pick<
TeamSlice,
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
> {
const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds };
const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds };
const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName];
const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName];
if (currentProvisioningRunId) {
nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName;
}
if (currentRuntimeRunId) {
nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName;
}
return {
ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
provisioningStartedAtFloorByTeam: {
...state.provisioningStartedAtFloorByTeam,
[teamName]: floor,
},
};
}
function captureTeamLocalStateEpoch(teamName: string): number {
return teamLocalStateEpochByTeam.get(teamName) ?? 0;
}
function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean {
return captureTeamLocalStateEpoch(teamName) === epoch;
}
function invalidateTeamLocalStateEpoch(teamName: string): void {
teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1);
}
function beginInFlightTeamDataRefresh(teamName: string): symbol {
const token = Symbol(teamName);
const existing = inFlightRefreshTeamDataCalls.get(teamName);
if (existing) {
existing.add(token);
return token;
}
inFlightRefreshTeamDataCalls.set(teamName, new Set([token]));
return token;
}
function endInFlightTeamDataRefresh(teamName: string, token: symbol): void {
const existing = inFlightRefreshTeamDataCalls.get(teamName);
if (!existing) {
return;
}
existing.delete(token);
if (existing.size === 0) {
inFlightRefreshTeamDataCalls.delete(teamName);
}
}
export function __getTeamScopedTransientStateForTests(teamName: string): {
hasResolvedMembersSelector: boolean;
resolvedMemberSelectorCount: number;
hasMergedMessagesSelector: boolean;
memberMessagesSelectorCount: number;
hasPendingFreshTeamDataRefresh: boolean;
hasQueuedHeadRefreshAfterOlder: boolean;
hasPendingFreshMessagesHeadRefresh: boolean;
hasPendingFreshMemberActivityMetaRefresh: boolean;
hasLastResolvedTeamDataRefresh: boolean;
hasCurrentLocalStateEpoch: boolean;
hasMemberSpawnStatusesIpcBackoff: boolean;
hasTeamRefreshBurstDiagnostics: boolean;
hasMemberSpawnUiEqualLastWarn: boolean;
} {
const teamScopedPrefix = `${teamName}:`;
let resolvedMemberSelectorCount = 0;
let memberMessagesSelectorCount = 0;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCount += 1;
}
}
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCount += 1;
}
}
return {
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
resolvedMemberSelectorCount,
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName),
hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName),
hasPendingFreshMemberActivityMetaRefresh:
pendingFreshTeamMemberActivityMetaRefreshes.has(teamName),
hasLastResolvedTeamDataRefresh: lastResolvedTeamDataRefreshAtByTeam.has(teamName),
hasCurrentLocalStateEpoch: teamLocalStateEpochByTeam.has(teamName),
hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName),
hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName),
hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName),
};
}
function nowIso(): string {
return new Date().toISOString();
}
@ -665,12 +939,32 @@ function clearPendingReplyRefreshTimer(teamName: string): void {
pendingTeamPendingReplyRefreshTimers.delete(teamName);
}
function setPendingReplyRefreshEnabled(teamName: string, enabled: boolean): void {
function clearPendingReplyRefreshWaits(teamName: string): void {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
}
function setPendingReplyRefreshEnabled(
teamName: string,
sourceId: string,
enabled: boolean
): boolean {
if (enabled) {
activeTeamPendingReplyWaits.add(teamName);
return;
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set<string>();
existing.add(sourceId);
activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing);
return true;
}
activeTeamPendingReplyWaits.delete(teamName);
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName);
if (!existing) {
return false;
}
existing.delete(sourceId);
if (existing.size === 0) {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
return false;
}
return true;
}
function getCanonicalHeadSlice(
@ -1738,7 +2032,12 @@ export interface TeamSlice {
refreshTeamMessagesHead: (teamName: string) => Promise<RefreshTeamMessagesHeadResult>;
loadOlderTeamMessages: (teamName: string) => Promise<void>;
refreshMemberActivityMeta: (teamName: string) => Promise<void>;
syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs?: number) => void;
syncTeamPendingReplyRefresh: (
teamName: string,
sourceId: string,
enabled: boolean,
delayMs?: number
) => void;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
crossTeamTargets: {
teamName: string;
@ -2059,17 +2358,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...prev.currentRuntimeRunIdByTeam,
[teamName]: snapshot.runId,
};
const hasIgnoredRuntimeEntriesForTeam = Object.values(prev.ignoredRuntimeRunIds).some(
(ignoredTeamName) => ignoredTeamName === teamName
);
const nextIgnoredRuntimeRunIds =
snapshot.runId == null || !hasIgnoredRuntimeEntriesForTeam
? prev.ignoredRuntimeRunIds
: Object.fromEntries(
Object.entries(prev.ignoredRuntimeRunIds).filter(
([, ignoredTeamName]) => ignoredTeamName !== teamName
)
);
// Keep same-team ignored runtime tombstones intact here.
// Member-spawn snapshots do not carry a run start time, so clearing older
// ignored ids can reopen stale zombie snapshots during create/launch churn.
const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName];
const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual(
previousSnapshot,
@ -2078,22 +2369,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (!snapshotChanged) {
maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId);
if (
nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam &&
nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds
) {
if (nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam) {
return {};
}
return {
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
};
}
return {
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: snapshot.statuses,
@ -2837,6 +3123,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectTeam: async (teamName: string, opts) => {
const startedAt = performance.now();
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
// Guard: prevent duplicate in-flight fetches for the same team.
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
@ -2865,6 +3152,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try {
const data = await fetchTeamDataDeduped(teamName);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
const ipcMs = performance.now() - startedAt;
// Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
@ -2993,6 +3283,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
}
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
// If provisioning is in progress for this team, stay in loading state;
// file watcher / progress callback will refresh once config is written.
const currentState = get();
@ -3041,7 +3334,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
const startedAt = performance.now();
inFlightRefreshTeamDataCalls.add(teamName);
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const refreshToken = beginInFlightTeamDataRefresh(teamName);
// Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load).
const reusedInFlightRequest =
@ -3058,6 +3352,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const data = opts?.withDedup
? await fetchTeamDataDeduped(teamName)
: await fetchTeamDataFresh(teamName);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
const ipcMs = performance.now() - startedAt;
const projectedTeamData = previousData
? {
@ -3124,6 +3421,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
burstCount,
});
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
const msg =
error instanceof IpcError
? error.message
@ -3182,7 +3482,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
set({ selectedTeamError: msg });
} finally {
inFlightRefreshTeamDataCalls.delete(teamName);
endInFlightTeamDataRefresh(teamName, refreshToken);
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
void get().refreshTeamData(teamName);
}
@ -3202,10 +3502,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
if (existingOlderRequest) {
const queuedEpoch = captureTeamLocalStateEpoch(teamName);
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
.then(() => {
if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) {
return {
feedChanged: false,
headChanged: false,
feedRevision: null,
};
}
if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) {
queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName);
} else {
return {
feedChanged: false,
headChanged: false,
feedRevision: null,
};
}
return get().refreshTeamMessagesHead(teamName);
})
@ -3218,7 +3532,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return queuedRequest;
}
const request = (async (): Promise<RefreshTeamMessagesHeadResult> => {
let request!: Promise<RefreshTeamMessagesHeadResult>;
request = (async (): Promise<RefreshTeamMessagesHeadResult> => {
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -3233,6 +3549,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const page = await unwrapIpc('team:getMessagesPage', () =>
api.teams.getMessagesPage(teamName, { limit: 50 })
);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return {
feedChanged: false,
headChanged: false,
feedRevision: null,
};
}
const previousEntry = getTeamMessagesCacheEntry(get(), teamName);
const feedChanged =
@ -3282,6 +3605,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
feedRevision: page.feedRevision,
};
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return {
feedChanged: false,
headChanged: false,
feedRevision: null,
};
}
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -3293,9 +3623,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}));
throw error;
} finally {
inFlightTeamMessagesHeadRequests.delete(teamName);
if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) {
void get().refreshTeamMessagesHead(teamName);
if (inFlightTeamMessagesHeadRequests.get(teamName) === request) {
inFlightTeamMessagesHeadRequests.delete(teamName);
if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) {
void get().refreshTeamMessagesHead(teamName);
}
}
}
})();
@ -3305,6 +3637,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
loadOlderTeamMessages: async (teamName: string) => {
const requestedEpoch = captureTeamLocalStateEpoch(teamName);
const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName);
if (existingRequest) {
return existingRequest;
@ -3313,11 +3646,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName);
if (existingHeadRequest) {
await existingHeadRequest;
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
return;
}
}
let entry = getTeamMessagesCacheEntry(get(), teamName);
if (!entry.headHydrated) {
await get().refreshTeamMessagesHead(teamName);
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
return;
}
entry = getTeamMessagesCacheEntry(get(), teamName);
}
@ -3325,7 +3664,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return;
}
const request = (async (): Promise<void> => {
let request!: Promise<void>;
request = (async (): Promise<void> => {
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -3344,6 +3685,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
limit: 50,
})
);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
const current = getTeamMessagesCacheEntry(get(), teamName);
if (current.feedRevision !== baseFeedRevision) {
@ -3392,6 +3736,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
};
});
} catch {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
set((state) => ({
teamMessagesByName: {
...state.teamMessagesByName,
@ -3402,7 +3749,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
}));
} finally {
inFlightTeamMessagesOlderRequests.delete(teamName);
if (inFlightTeamMessagesOlderRequests.get(teamName) === request) {
inFlightTeamMessagesOlderRequests.delete(teamName);
}
}
})();
@ -3422,11 +3771,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return existingRequest;
}
const request = (async (): Promise<void> => {
let request!: Promise<void>;
request = (async (): Promise<void> => {
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
try {
const meta = await unwrapIpc('team:getMemberActivityMeta', () =>
api.teams.getMemberActivityMeta(teamName)
);
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
set((state) => {
const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision;
@ -3458,10 +3812,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
};
});
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
return;
}
throw error;
} finally {
inFlightTeamMemberActivityMetaRequests.delete(teamName);
if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) {
void get().refreshMemberActivityMeta(teamName);
if (inFlightTeamMemberActivityMetaRequests.get(teamName) === request) {
inFlightTeamMemberActivityMetaRequests.delete(teamName);
if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) {
void get().refreshMemberActivityMeta(teamName);
}
}
}
})();
@ -3470,10 +3831,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return request;
},
syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs = 10_000) => {
syncTeamPendingReplyRefresh: (
teamName: string,
sourceId: string,
enabled: boolean,
delayMs = 10_000
) => {
clearPendingReplyRefreshTimer(teamName);
setPendingReplyRefreshEnabled(teamName, enabled);
if (!enabled) {
const shouldKeepRefreshActive = setPendingReplyRefreshEnabled(teamName, sourceId, enabled);
if (!shouldKeepRefreshActive) {
return;
}
@ -3770,42 +4136,26 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
invalidateTeamLocalStateEpoch(teamName);
clearPendingReplyRefreshTimer(teamName);
setPendingReplyRefreshEnabled(teamName, false);
clearPendingReplyRefreshWaits(teamName);
clearTeamScopedTransientState(teamName);
set((state) => {
const nextCache = state.teamDataCacheByName[teamName]
? { ...state.teamDataCacheByName }
: null;
const nextMessageCache = state.teamMessagesByName[teamName]
? { ...state.teamMessagesByName }
: null;
const nextActivityMeta = state.memberActivityMetaByTeam[teamName]
? { ...state.memberActivityMetaByTeam }
: null;
if (nextCache) {
delete nextCache[teamName];
}
if (nextMessageCache) {
delete nextMessageCache[teamName];
}
if (nextActivityMeta) {
delete nextActivityMeta[teamName];
}
const clearedState = collectTeamScopedStateRemovals(state, teamName);
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
if (state.selectedTeamName === teamName) {
return {
selectedTeamName: null,
selectedTeamData: null,
selectedTeamLoading: false,
selectedTeamError: null,
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}),
...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}),
...clearedState,
...tombstones,
};
}
return {
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}),
...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}),
...clearedState,
...tombstones,
};
});
await get().fetchTeams();
@ -3814,32 +4164,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
restoreTeam: async (teamName: string) => {
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
invalidateTeamLocalStateEpoch(teamName);
clearPendingReplyRefreshTimer(teamName);
setPendingReplyRefreshEnabled(teamName, false);
clearPendingReplyRefreshWaits(teamName);
clearTeamScopedTransientState(teamName);
set((state) => {
const hasSnapshot = Boolean(state.teamDataCacheByName[teamName]);
const hasMessages = Boolean(state.teamMessagesByName[teamName]);
const hasMeta = Boolean(state.memberActivityMetaByTeam[teamName]);
if (!hasSnapshot && !hasMessages && !hasMeta) {
return {};
const clearedState = collectTeamScopedStateRemovals(state, teamName);
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
if (Object.keys(clearedState).length === 0) {
return tombstones;
}
const nextState: Partial<TeamSlice> = {};
if (hasSnapshot) {
const nextCache = { ...state.teamDataCacheByName };
delete nextCache[teamName];
nextState.teamDataCacheByName = nextCache;
}
if (hasMessages) {
const nextMessages = { ...state.teamMessagesByName };
delete nextMessages[teamName];
nextState.teamMessagesByName = nextMessages;
}
if (hasMeta) {
const nextMeta = { ...state.memberActivityMetaByTeam };
delete nextMeta[teamName];
nextState.memberActivityMetaByTeam = nextMeta;
}
return nextState;
return {
...clearedState,
...tombstones,
};
});
await get().fetchTeams();
await get().fetchAllTasks();
@ -3847,35 +4185,28 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
permanentlyDeleteTeam: async (teamName: string) => {
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
invalidateTeamLocalStateEpoch(teamName);
clearPendingReplyRefreshTimer(teamName);
setPendingReplyRefreshEnabled(teamName, false);
clearPendingReplyRefreshWaits(teamName);
clearTeamScopedTransientState(teamName);
const state = get();
const nextCache = { ...state.teamDataCacheByName };
const nextMessages = { ...state.teamMessagesByName };
const nextMeta = { ...state.memberActivityMetaByTeam };
delete nextCache[teamName];
delete nextMessages[teamName];
delete nextMeta[teamName];
const clearedState = collectTeamScopedStateRemovals(state, teamName);
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
if (state.selectedTeamName === teamName) {
set({
selectedTeamName: null,
selectedTeamData: null,
selectedTeamError: null,
teamDataCacheByName: nextCache,
teamMessagesByName: nextMessages,
memberActivityMetaByTeam: nextMeta,
...clearedState,
...tombstones,
});
} else if (state.teamDataCacheByName[teamName]) {
} else if (Object.keys(clearedState).length > 0) {
set({
teamDataCacheByName: nextCache,
teamMessagesByName: nextMessages,
memberActivityMetaByTeam: nextMeta,
});
} else if (state.teamMessagesByName[teamName] || state.memberActivityMetaByTeam[teamName]) {
set({
teamMessagesByName: nextMessages,
memberActivityMetaByTeam: nextMeta,
...clearedState,
...tombstones,
});
} else {
set(tombstones);
}
await get().fetchTeams();
await get().fetchAllTasks();
@ -3884,6 +4215,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
createTeam: async (request: TeamCreateRequest) => {
// Ensure provisioning progress subscription is active (defensive).
get().subscribeProvisioningProgress();
invalidateTeamLocalStateEpoch(request.teamName);
clearPendingReplyRefreshTimer(request.teamName);
clearPendingReplyRefreshWaits(request.teamName);
clearTeamScopedTransientState(request.teamName);
// Establish a per-team floor so late events from a previous run can't override UI.
const floor = nowIso();
@ -3917,25 +4252,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
? {
...Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
...state.ignoredRuntimeRunIds,
[previousRuntimeRunId]: request.teamName,
}
: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
: state.ignoredRuntimeRunIds;
const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
@ -3945,8 +4268,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
ignoredProvisioningRunIds: state.ignoredProvisioningRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
...visibleLoadingResets,
};
});
@ -4038,11 +4362,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
};
});
try {
@ -4082,6 +4401,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
launchTeam: async (request: TeamLaunchRequest) => {
// Ensure provisioning progress subscription is active (defensive).
get().subscribeProvisioningProgress();
invalidateTeamLocalStateEpoch(request.teamName);
clearPendingReplyRefreshTimer(request.teamName);
clearPendingReplyRefreshWaits(request.teamName);
clearTeamScopedTransientState(request.teamName);
// Establish a per-team floor so late events from a previous run can't override UI.
const floor = nowIso();
@ -4115,25 +4438,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
? {
...Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
...state.ignoredRuntimeRunIds,
[previousRuntimeRunId]: request.teamName,
}
: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
: state.ignoredRuntimeRunIds;
const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
@ -4143,8 +4454,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
ignoredProvisioningRunIds: state.ignoredProvisioningRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
...visibleLoadingResets,
};
});
@ -4218,11 +4530,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
};
});
try {
@ -4418,11 +4725,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[progress.teamName]: progress.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== progress.teamName
)
),
provisioningErrorByTeam: nextErrors,
provisioningSnapshotByTeam: nextSnapshots,
};

View file

@ -809,17 +809,20 @@ describe('ipc teams handlers', () => {
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => {
it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
const result = (await handler({} as never, 'my-team')) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getTeamData).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.data?.teamName).toBe('my-team');
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
@ -894,7 +897,7 @@ describe('ipc teams handlers', () => {
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => {
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
@ -902,11 +905,14 @@ describe('ipc teams handlers', () => {
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; error?: string };
})) as { success: boolean; data?: { feedRevision: string } };
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getMessagesPage).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.data?.feedRevision).toBe('rev-1');
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
cursor: undefined,
limit: 50,
});
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
@ -940,17 +946,20 @@ describe('ipc teams handlers', () => {
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
});
it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => {
it('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
const result = (await handler({} as never, 'my-team')) as {
success: boolean;
data?: { feedRevision: string };
};
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.data?.feedRevision).toBe('rev-1');
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;

View file

@ -169,7 +169,6 @@ function createMemberSpawnRun(params?: {
expectedMembers?: string[];
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
memberSpawnProcessedLeadInboxMessageIdsByMember?: Map<string, Set<string>>;
}) {
const teamName = params?.teamName ?? 'member-spawn-team';
const expectedMembers = params?.expectedMembers ?? ['alice'];
@ -196,8 +195,6 @@ function createMemberSpawnRun(params?: {
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember:
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
memberSpawnProcessedLeadInboxMessageIdsByMember:
params?.memberSpawnProcessedLeadInboxMessageIdsByMember ?? new Map(),
provisioningOutputParts: [],
activeToolCalls: new Map(),
isLaunch: false,
@ -986,9 +983,6 @@ describe('TeamProvisioningService', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
['alice', new Set(['msg-1', 'msg-2'])],
]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
@ -1053,9 +1047,6 @@ describe('TeamProvisioningService', () => {
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-1',
});
expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')).toEqual(
new Set(['msg-1'])
);
});
it('ignores teammate lead inbox signals that predate the current run', async () => {
@ -1080,7 +1071,6 @@ describe('TeamProvisioningService', () => {
expect(applySignalSpy).not.toHaveBeenCalled();
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.size).toBe(0);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
@ -1088,7 +1078,7 @@ describe('TeamProvisioningService', () => {
});
});
it('marks an unseen older lead inbox signal as processed without replaying older state', async () => {
it('ignores an unseen older lead inbox signal without replaying older state', async () => {
const latestHeartbeatAt = '2026-04-16T10:05:00.000Z';
const existingEntry = createMemberSpawnStatusEntry({
status: 'online',
@ -1101,9 +1091,6 @@ describe('TeamProvisioningService', () => {
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnStatuses: new Map([['alice', existingEntry]]),
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
['alice', new Set(['msg-3'])],
]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
@ -1139,9 +1126,6 @@ describe('TeamProvisioningService', () => {
expect(applySignalSpy).not.toHaveBeenCalled();
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
expect(
run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')?.has('msg-2b')
).toBe(true);
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
timestamp: latestHeartbeatAt,
messageId: 'msg-3',
@ -1165,9 +1149,6 @@ describe('TeamProvisioningService', () => {
}),
],
]),
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
['alice', new Set(['msg-1'])],
]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
@ -1202,17 +1183,11 @@ describe('TeamProvisioningService', () => {
timestamp: '2026-04-16T10:01:00.000Z',
messageId: 'msg-2',
});
expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')).toEqual(
new Set(['msg-1', 'msg-2'])
);
});
it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => {
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
['alice', new Set(['msg-2'])],
]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
@ -1256,9 +1231,6 @@ describe('TeamProvisioningService', () => {
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-3',
});
expect(
run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')
).toEqual(new Set(['msg-2', 'msg-3']));
});
it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => {

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { buildProviderPrepareModelCacheKey } from '@renderer/components/team/dialogs/providerPrepareCacheKey';
describe('buildProviderPrepareModelCacheKey', () => {
it('separates limit-context variants for the same provider runtime', () => {
const sharedInput = {
cwd: '/tmp/project',
providerId: 'anthropic' as const,
backendSummary: 'Claude Code',
};
expect(
buildProviderPrepareModelCacheKey({
...sharedInput,
limitContext: false,
})
).not.toBe(
buildProviderPrepareModelCacheKey({
...sharedInput,
limitContext: true,
})
);
});
it('still reuses cache for identical runtime conditions', () => {
const input = {
cwd: '/tmp/project',
providerId: 'codex' as const,
backendSummary: 'Default adapter',
limitContext: false,
};
expect(buildProviderPrepareModelCacheKey(input)).toBe(buildProviderPrepareModelCacheKey(input));
});
});

View file

@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { runProviderPrepareDiagnostics } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import {
buildReusableProviderPrepareModelResults,
runProviderPrepareDiagnostics,
} from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import type { TeamProvisioningPrepareResult } from '@shared/types';
@ -17,6 +20,39 @@ function createDeferred<T>(): {
}
describe('runProviderPrepareDiagnostics', () => {
it('does not keep transient note results in the reusable cache', () => {
expect(
buildReusableProviderPrepareModelResults({
'gpt-5.4': {
status: 'ready',
line: '5.4 - verified',
warningLine: null,
},
'gpt-5.3-codex': {
status: 'notes',
line: '5.3 Codex - check failed - Model verification timed out',
warningLine: '5.3 Codex - check failed - Model verification timed out',
},
'gpt-5.2-codex': {
status: 'failed',
line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription',
warningLine: null,
},
})
).toEqual({
'gpt-5.4': {
status: 'ready',
line: '5.4 - verified',
warningLine: null,
},
'gpt-5.2-codex': {
status: 'failed',
line: '5.2 Codex - unavailable - Not available with Codex ChatGPT subscription',
warningLine: null,
},
});
});
it('returns a failed provider result immediately when runtime preflight fails', async () => {
const prepareProvisioning = vi.fn<
(
@ -42,9 +78,8 @@ describe('runProviderPrepareDiagnostics', () => {
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
});
it('emits per-model progress updates and keeps failures scoped to the affected model', async () => {
const deferred54 = createDeferred<TeamProvisioningPrepareResult>();
const deferred52 = createDeferred<TeamProvisioningPrepareResult>();
it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => {
const deferredBatch = createDeferred<TeamProvisioningPrepareResult>();
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
[];
@ -62,10 +97,8 @@ describe('runProviderPrepareDiagnostics', () => {
message: 'CLI is warmed up and ready to launch',
});
}
if (selectedModels[0] === 'gpt-5.4') {
return deferred54.promise;
}
return deferred52.promise;
expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']);
return deferredBatch.promise;
});
const resultPromise = runProviderPrepareDiagnostics({
@ -83,24 +116,13 @@ describe('runProviderPrepareDiagnostics', () => {
details: ['5.4 - checking...', '5.2 Codex - checking...'],
});
deferred54.resolve({
ready: true,
message: 'CLI is warmed up and ready to launch',
details: ['Selected model gpt-5.4 verified for launch.'],
});
await Promise.resolve();
await Promise.resolve();
expect(progressUpdates.at(-1)).toEqual({
completedCount: 1,
totalCount: 2,
details: ['5.4 - verified', '5.2 Codex - checking...'],
});
deferred52.resolve({
deferredBatch.resolve({
ready: false,
message:
message: 'Some provider runtimes are not ready',
details: ['Selected model gpt-5.4 verified for launch.'],
warnings: [
"Selected model gpt-5.2-codex is unavailable. The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.",
],
});
const result = await resultPromise;
@ -117,6 +139,7 @@ describe('runProviderPrepareDiagnostics', () => {
'5.2 Codex - unavailable - Not available with Codex ChatGPT subscription',
],
});
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('normalizes raw Codex API error envelopes into a clean model reason', async () => {

View file

@ -0,0 +1,213 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
vi.mock('@renderer/hooks/useMemberStats', () => ({
useMemberStats: () => ({
stats: null,
loading: false,
error: null,
}),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) =>
React.createElement(
'button',
{
type: 'button',
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogFooter: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tabs', () => {
let currentValue = '';
let currentOnValueChange: ((value: string) => void) | null = null;
return {
Tabs: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value: string;
onValueChange?: (value: string) => void;
}) => {
currentValue = value;
currentOnValueChange = onValueChange ?? null;
return React.createElement('div', { 'data-tabs-value': value }, children);
},
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
TabsTrigger: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) =>
React.createElement(
'button',
{
type: 'button',
'data-state': currentValue === value ? 'active' : 'inactive',
onClick: () => currentOnValueChange?.(value),
},
children
),
TabsContent: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => (currentValue === value ? React.createElement('div', null, children) : null),
};
});
vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({
MemberDetailHeader: () => React.createElement('div', null, 'header'),
}));
vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({
MemberDetailStats: ({ activityCount }: { activityCount: number }) =>
React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`),
}));
vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({
MemberTasksTab: () => React.createElement('div', null, 'tasks-tab'),
}));
vi.mock('@renderer/components/team/members/MemberMessagesTab', () => ({
MemberMessagesTab: () => React.createElement('div', null, 'activity-tab'),
}));
vi.mock('@renderer/components/team/members/MemberStatsTab', () => ({
MemberStatsTab: () => React.createElement('div', null, 'stats-tab'),
}));
vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
MemberLogsTab: () => React.createElement('div', null, 'logs-tab'),
}));
import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog';
describe('MemberDetailDialog activity count', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({
teamMessagesByName: {
'demo-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'rev-empty',
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
} as never);
});
afterEach(() => {
document.body.innerHTML = '';
useStore.setState({ teamMessagesByName: {} } as never);
vi.unstubAllGlobals();
});
it('counts task comments in the Activity badge even when messageCount is zero', async () => {
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
};
const members: ResolvedTeamMember[] = [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
member,
];
const tasks: TeamTaskWithKanban[] = [
{
id: 'task-1',
displayId: '#1',
subject: 'Review patch',
owner: 'jack',
status: 'in_progress',
comments: [
{
id: 'comment-1',
author: 'jack',
text: 'Left a review note',
createdAt: '2026-04-17T10:00:00.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
];
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members,
tasks,
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('activity-count:1');
expect(host.textContent).toContain('Activity1');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -194,7 +194,7 @@ describe('MemberMessagesTab', () => {
});
});
it('hides load older messages when the member has no visible activity', async () => {
it('shows load older messages when older pages may still contain this member activity', async () => {
getMessagesPage.mockResolvedValue({
messages: [
{
@ -282,8 +282,8 @@ describe('MemberMessagesTab', () => {
});
expect(getMessagesPage).not.toHaveBeenCalled();
expect(host.textContent).toContain('No activity with this member');
expect(host.textContent).not.toContain('Load older messages');
expect(host.textContent).toContain('No loaded activity for this member yet');
expect(host.textContent).toContain('Load older messages');
await act(async () => {
root.unmount();

View file

@ -219,7 +219,7 @@ describe('team change throttling', () => {
});
it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => {
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
@ -304,7 +304,7 @@ describe('team change throttling', () => {
});
it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => {
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000);
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');

File diff suppressed because it is too large Load diff