fix(team): harden refresh races and loading state
This commit is contained in:
parent
a5c79518fb
commit
351244ffdb
19 changed files with 2141 additions and 414 deletions
|
|
@ -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!);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('::');
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
213
test/renderer/components/team/members/MemberDetailDialog.test.ts
Normal file
213
test/renderer/components/team/members/MemberDetailDialog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue