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 seenRateLimitKeys = new Set<string>();
|
||||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
|
||||||
function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void {
|
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
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(
|
async function getDurableLeadTeammateRoster(
|
||||||
|
|
@ -749,11 +748,11 @@ async function handleGetData(
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
);
|
);
|
||||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1700,7 +1699,7 @@ async function handleGetMessagesPage(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage');
|
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||||
scanTeamMessageNotifications(
|
scanTeamMessageNotifications(
|
||||||
page.messages,
|
page.messages,
|
||||||
|
|
@ -1734,7 +1733,7 @@ async function handleGetMemberActivityMeta(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta');
|
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
|
||||||
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -702,11 +702,6 @@ interface ProvisioningRun {
|
||||||
memberSpawnToolUseIds: Map<string, string>;
|
memberSpawnToolUseIds: Map<string, string>;
|
||||||
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||||
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
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. */
|
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||||
lastDeterministicBootstrapSeq: number;
|
lastDeterministicBootstrapSeq: number;
|
||||||
/** Throttles config/inbox audit work triggered by frequent status polling. */
|
/** Throttles config/inbox audit work triggered by frequent status polling. */
|
||||||
|
|
@ -842,19 +837,6 @@ function maxMemberSpawnInboxCursor(
|
||||||
return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right;
|
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(
|
function isMemberSpawnHeartbeatTimestampNewer(
|
||||||
previous: string | undefined,
|
previous: string | undefined,
|
||||||
incoming: string | undefined
|
incoming: string | undefined
|
||||||
|
|
@ -2965,42 +2947,28 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [memberName, messages] of messagesByMember.entries()) {
|
for (const [memberName, messages] of messagesByMember.entries()) {
|
||||||
const processedMessageIds = getOrCreateMemberSpawnProcessedMessageIds(run, memberName);
|
|
||||||
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
|
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
|
||||||
const newlyProcessedMessageIds: string[] = [];
|
|
||||||
let nextCursor = currentCursor;
|
let nextCursor = currentCursor;
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (processedMessageIds.has(message.messageId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageCursor = toMemberSpawnInboxCursor(message);
|
const messageCursor = toMemberSpawnInboxCursor(message);
|
||||||
const shouldApplySignal =
|
const effectiveCursor = nextCursor ?? currentCursor;
|
||||||
messageCursor == null ||
|
if (messageCursor && effectiveCursor) {
|
||||||
currentCursor == null ||
|
if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) {
|
||||||
compareMemberSpawnInboxCursor(messageCursor, currentCursor) > 0;
|
continue;
|
||||||
|
|
||||||
if (shouldApplySignal) {
|
|
||||||
this.applyLeadInboxSpawnSignal(run, memberName, message);
|
|
||||||
if (messageCursor) {
|
|
||||||
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark late out-of-order signals as seen so they cannot replay forever, but only
|
this.applyLeadInboxSpawnSignal(run, memberName, message);
|
||||||
// let strictly newer cursors mutate the already-advanced live member state.
|
if (messageCursor) {
|
||||||
newlyProcessedMessageIds.push(message.messageId);
|
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newlyProcessedMessageIds.length === 0) {
|
if (
|
||||||
continue;
|
nextCursor &&
|
||||||
}
|
(currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0)
|
||||||
|
) {
|
||||||
for (const messageId of newlyProcessedMessageIds) {
|
|
||||||
processedMessageIds.add(messageId);
|
|
||||||
}
|
|
||||||
if (nextCursor) {
|
|
||||||
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
|
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5127,7 +5095,6 @@ export class TeamProvisioningService {
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(),
|
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
||||||
|
|
@ -5708,7 +5675,6 @@ export class TeamProvisioningService {
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map(),
|
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
lastMemberSpawnAuditConfigReadWarningAt: 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 { api } from '@renderer/api';
|
||||||
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||||
|
|
@ -1308,6 +1318,7 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const leadSessionId = data?.config.leadSessionId ?? null;
|
const leadSessionId = data?.config.leadSessionId ?? null;
|
||||||
|
const pendingReplyRefreshSourceId = useId();
|
||||||
const sessionHistoryKey = useMemo(
|
const sessionHistoryKey = useMemo(
|
||||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||||
[data?.config.sessionHistory]
|
[data?.config.sessionHistory]
|
||||||
|
|
@ -1320,14 +1331,21 @@ export const TeamDetailView = ({
|
||||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||||
syncTeamPendingReplyRefresh(
|
syncTeamPendingReplyRefresh(
|
||||||
teamName,
|
teamName,
|
||||||
|
pendingReplyRefreshSourceId,
|
||||||
Boolean(data?.isAlive) && hasPendingReplies,
|
Boolean(data?.isAlive) && hasPendingReplies,
|
||||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
syncTeamPendingReplyRefresh(teamName, false);
|
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||||||
};
|
};
|
||||||
}, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
|
}, [
|
||||||
|
data?.isAlive,
|
||||||
|
pendingRepliesByMember,
|
||||||
|
pendingReplyRefreshSourceId,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import {
|
import {
|
||||||
|
buildReusableProviderPrepareModelResults,
|
||||||
getProviderPrepareCachedSnapshot,
|
getProviderPrepareCachedSnapshot,
|
||||||
type ProviderPrepareDiagnosticsModelResult,
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
runProviderPrepareDiagnostics,
|
runProviderPrepareDiagnostics,
|
||||||
|
|
@ -125,14 +127,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrepareModelCacheKey(
|
|
||||||
cwd: string,
|
|
||||||
providerId: TeamProviderId,
|
|
||||||
backendSummary: string | null | undefined
|
|
||||||
): string {
|
|
||||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alignProvisioningChecks(
|
function alignProvisioningChecks(
|
||||||
existingChecks: ProvisioningProviderCheck[],
|
existingChecks: ProvisioningProviderCheck[],
|
||||||
providerIds: TeamProviderId[]
|
providerIds: TeamProviderId[]
|
||||||
|
|
@ -644,7 +638,12 @@ export const CreateTeamDialog = ({
|
||||||
return Array.from(next);
|
return Array.from(next);
|
||||||
})();
|
})();
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
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 cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -714,7 +713,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(
|
prepareModelResultsCacheRef.current.set(
|
||||||
plan.cacheKey,
|
plan.cacheKey,
|
||||||
plan.prepResult.modelResultsById
|
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||||
);
|
);
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
status: plan.prepResult.status,
|
status: plan.prepResult.status,
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,10 @@ import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import {
|
import {
|
||||||
|
buildReusableProviderPrepareModelResults,
|
||||||
getProviderPrepareCachedSnapshot,
|
getProviderPrepareCachedSnapshot,
|
||||||
type ProviderPrepareDiagnosticsModelResult,
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
runProviderPrepareDiagnostics,
|
runProviderPrepareDiagnostics,
|
||||||
|
|
@ -110,14 +112,6 @@ import type {
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
function buildPrepareModelCacheKey(
|
|
||||||
cwd: string,
|
|
||||||
providerId: TeamProviderId,
|
|
||||||
backendSummary: string | null | undefined
|
|
||||||
): string {
|
|
||||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alignProvisioningChecks(
|
function alignProvisioningChecks(
|
||||||
existingChecks: ProvisioningProviderCheck[],
|
existingChecks: ProvisioningProviderCheck[],
|
||||||
providerIds: TeamProviderId[]
|
providerIds: TeamProviderId[]
|
||||||
|
|
@ -933,7 +927,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||||
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
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 cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -1001,7 +1000,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
} else if (plan.prepResult.status === 'notes') {
|
} else if (plan.prepResult.status === 'notes') {
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
|
prepareModelResultsCacheRef.current.set(
|
||||||
|
plan.cacheKey,
|
||||||
|
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||||
|
);
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
status: plan.prepResult.status,
|
status: plan.prepResult.status,
|
||||||
backendSummary: plan.backendSummary,
|
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>;
|
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 {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
|
||||||
return null;
|
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(
|
function buildModelFailureLine(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
|
|
@ -201,6 +232,72 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
|
||||||
return [...(result.details ?? []), ...(result.warnings ?? [])];
|
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({
|
export async function runProviderPrepareDiagnostics({
|
||||||
cwd,
|
cwd,
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -289,71 +386,55 @@ export async function runProviderPrepareDiagnostics({
|
||||||
|
|
||||||
emitProgress();
|
emitProgress();
|
||||||
|
|
||||||
await Promise.all(
|
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
|
||||||
orderedModelIds
|
if (uncachedModelIds.length > 0) {
|
||||||
.filter((modelId) => !modelResultsById.has(modelId))
|
try {
|
||||||
.map(async (modelId) => {
|
const batchedModelResult = await prepareProvisioning(
|
||||||
try {
|
cwd,
|
||||||
const modelResult = await prepareProvisioning(
|
providerId,
|
||||||
cwd,
|
[providerId],
|
||||||
providerId,
|
uncachedModelIds,
|
||||||
[providerId],
|
limitContext
|
||||||
[modelId],
|
);
|
||||||
limitContext
|
|
||||||
);
|
for (const modelId of uncachedModelIds) {
|
||||||
if (!modelResult.ready) {
|
const resolvedResult = resolveModelResultFromBatch(
|
||||||
hasFailure = true;
|
providerId,
|
||||||
const line = buildModelFailureLine(
|
modelId,
|
||||||
providerId,
|
batchedModelResult,
|
||||||
modelId,
|
uncachedModelIds.length === 1
|
||||||
'unavailable',
|
);
|
||||||
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
|
modelLines.set(modelId, resolvedResult.line);
|
||||||
);
|
modelResultsById.set(modelId, resolvedResult);
|
||||||
modelLines.set(modelId, line);
|
if (resolvedResult.status === 'failed') {
|
||||||
modelResultsById.set(modelId, {
|
hasFailure = true;
|
||||||
status: 'failed',
|
} else if (resolvedResult.status === 'notes') {
|
||||||
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) {
|
|
||||||
hasNotes = true;
|
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 dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
|
||||||
const selectedModelResultsById = Object.fromEntries(
|
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 { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
|
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
import { MemberDetailStats } from './MemberDetailStats';
|
import { MemberDetailStats } from './MemberDetailStats';
|
||||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||||
import { MemberLogsTab } from './MemberLogsTab';
|
import { MemberLogsTab } from './MemberLogsTab';
|
||||||
|
|
@ -71,7 +74,21 @@ export const MemberDetailDialog = ({
|
||||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||||
[tasks, member]
|
[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(
|
const inProgressTasks = useMemo(
|
||||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
|
|
||||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
import {
|
import {
|
||||||
buildMessageContext,
|
buildMessageContext,
|
||||||
|
|
@ -11,11 +10,11 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
|
|
||||||
import type { MemberActivityFilter } from './memberDetailTypes';
|
import type { MemberActivityFilter } from './memberDetailTypes';
|
||||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -56,13 +55,6 @@ export const MemberMessagesTab = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { readSet } = useTeamMessagesRead(teamName);
|
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 taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||||
|
|
||||||
|
|
@ -80,45 +72,34 @@ export const MemberMessagesTab = ({
|
||||||
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||||
const hasMore = messagesState?.hasMore ?? 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 activityEntries = useMemo(() => {
|
||||||
const entriesByOwner = buildInlineActivityEntries({
|
return buildMemberActivityEntries({
|
||||||
data: {
|
|
||||||
members,
|
|
||||||
tasks,
|
|
||||||
messages: filteredMessages,
|
|
||||||
},
|
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
memberName,
|
||||||
leadName,
|
members,
|
||||||
ownerNodeIds,
|
tasks,
|
||||||
|
messages,
|
||||||
});
|
});
|
||||||
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
|
}, [memberName, members, messages, tasks, teamName]);
|
||||||
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
|
const visibleActivityEntries = useMemo(
|
||||||
|
() => activityEntries.slice(0, MAX_MESSAGES),
|
||||||
|
[activityEntries]
|
||||||
|
);
|
||||||
|
|
||||||
const displayEntries = useMemo(() => {
|
const displayEntries = useMemo(() => {
|
||||||
switch (activityFilter) {
|
switch (activityFilter) {
|
||||||
case 'messages':
|
case 'messages':
|
||||||
return activityEntries.filter(
|
return visibleActivityEntries.filter(
|
||||||
(entry) => entry.message.messageKind !== 'task_comment_notification'
|
(entry) => entry.message.messageKind !== 'task_comment_notification'
|
||||||
);
|
);
|
||||||
case 'comments':
|
case 'comments':
|
||||||
return activityEntries.filter(
|
return visibleActivityEntries.filter(
|
||||||
(entry) => entry.message.messageKind === 'task_comment_notification'
|
(entry) => entry.message.messageKind === 'task_comment_notification'
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return activityEntries;
|
return visibleActivityEntries;
|
||||||
}
|
}
|
||||||
}, [activityEntries, activityFilter]);
|
}, [activityFilter, visibleActivityEntries]);
|
||||||
|
|
||||||
const expandedItemsByKey = useMemo(() => {
|
const expandedItemsByKey = useMemo(() => {
|
||||||
const items = new Map<string, TimelineItem>();
|
const items = new Map<string, TimelineItem>();
|
||||||
|
|
@ -156,9 +137,10 @@ export const MemberMessagesTab = ({
|
||||||
? hasMore
|
? hasMore
|
||||||
? 'No loaded messages for this member yet'
|
? 'No loaded messages for this member yet'
|
||||||
: 'No messages with this member'
|
: 'No messages with this member'
|
||||||
: 'No activity with this member';
|
: hasMore
|
||||||
const canLoadOlderMessages =
|
? 'No loaded activity for this member yet'
|
||||||
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
|
: 'No activity with this member';
|
||||||
|
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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 TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
|
||||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
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 pendingFreshTeamDataRefreshes = new Set<string>();
|
||||||
const inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
|
const inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
|
||||||
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
|
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
|
||||||
|
|
@ -91,8 +91,9 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set<string>();
|
||||||
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
|
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
|
||||||
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
|
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
|
||||||
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
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 lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
|
||||||
|
const teamLocalStateEpochByTeam = new Map<string, number>();
|
||||||
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
||||||
let pendingFreshGlobalTasksRefresh = false;
|
let pendingFreshGlobalTasksRefresh = false;
|
||||||
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
|
||||||
|
|
@ -119,7 +120,7 @@ interface TeamGraphLayoutSessionState {
|
||||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
inFlightTeamDataRequests.has(teamName) ||
|
inFlightTeamDataRequests.has(teamName) ||
|
||||||
inFlightRefreshTeamDataCalls.has(teamName) ||
|
(inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 ||
|
||||||
pendingFreshTeamDataRefreshes.has(teamName)
|
pendingFreshTeamDataRefreshes.has(teamName)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -129,11 +130,15 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
|
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
|
||||||
return activeTeamPendingReplyWaits.has(teamName);
|
return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveTeamPendingReplyWaits(): Set<string> {
|
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 {
|
export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
|
|
@ -150,8 +155,9 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
pendingTeamPendingReplyRefreshTimers.clear();
|
pendingTeamPendingReplyRefreshTimers.clear();
|
||||||
activeTeamPendingReplyWaits.clear();
|
activeTeamPendingReplyWaitSourceIdsByTeam.clear();
|
||||||
lastResolvedTeamDataRefreshAtByTeam.clear();
|
lastResolvedTeamDataRefreshAtByTeam.clear();
|
||||||
|
teamLocalStateEpochByTeam.clear();
|
||||||
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
|
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
|
||||||
teamRefreshBurstDiagnostics.clear();
|
teamRefreshBurstDiagnostics.clear();
|
||||||
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
||||||
|
|
@ -161,6 +167,274 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
memberMessagesSelectorCache.clear();
|
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 {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -665,12 +939,32 @@ function clearPendingReplyRefreshTimer(teamName: string): void {
|
||||||
pendingTeamPendingReplyRefreshTimers.delete(teamName);
|
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) {
|
if (enabled) {
|
||||||
activeTeamPendingReplyWaits.add(teamName);
|
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set<string>();
|
||||||
return;
|
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(
|
function getCanonicalHeadSlice(
|
||||||
|
|
@ -1738,7 +2032,12 @@ export interface TeamSlice {
|
||||||
refreshTeamMessagesHead: (teamName: string) => Promise<RefreshTeamMessagesHeadResult>;
|
refreshTeamMessagesHead: (teamName: string) => Promise<RefreshTeamMessagesHeadResult>;
|
||||||
loadOlderTeamMessages: (teamName: string) => Promise<void>;
|
loadOlderTeamMessages: (teamName: string) => Promise<void>;
|
||||||
refreshMemberActivityMeta: (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>;
|
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
||||||
crossTeamTargets: {
|
crossTeamTargets: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -2059,17 +2358,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
...prev.currentRuntimeRunIdByTeam,
|
...prev.currentRuntimeRunIdByTeam,
|
||||||
[teamName]: snapshot.runId,
|
[teamName]: snapshot.runId,
|
||||||
};
|
};
|
||||||
const hasIgnoredRuntimeEntriesForTeam = Object.values(prev.ignoredRuntimeRunIds).some(
|
// Keep same-team ignored runtime tombstones intact here.
|
||||||
(ignoredTeamName) => ignoredTeamName === teamName
|
// 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 nextIgnoredRuntimeRunIds =
|
|
||||||
snapshot.runId == null || !hasIgnoredRuntimeEntriesForTeam
|
|
||||||
? prev.ignoredRuntimeRunIds
|
|
||||||
: Object.fromEntries(
|
|
||||||
Object.entries(prev.ignoredRuntimeRunIds).filter(
|
|
||||||
([, ignoredTeamName]) => ignoredTeamName !== teamName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName];
|
const previousSnapshot = prev.memberSpawnSnapshotsByTeam[teamName];
|
||||||
const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual(
|
const snapshotChanged = !areMemberSpawnSnapshotsSemanticallyEqual(
|
||||||
previousSnapshot,
|
previousSnapshot,
|
||||||
|
|
@ -2078,22 +2369,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
if (!snapshotChanged) {
|
if (!snapshotChanged) {
|
||||||
maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId);
|
maybeLogMemberSpawnUiEqualSuppressed(teamName, snapshot.runId);
|
||||||
if (
|
if (nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam) {
|
||||||
nextCurrentRuntimeRunIdByTeam === prev.currentRuntimeRunIdByTeam &&
|
|
||||||
nextIgnoredRuntimeRunIds === prev.ignoredRuntimeRunIds
|
|
||||||
) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
currentRuntimeRunIdByTeam: nextCurrentRuntimeRunIdByTeam,
|
||||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
|
||||||
memberSpawnStatusesByTeam: {
|
memberSpawnStatusesByTeam: {
|
||||||
...prev.memberSpawnStatusesByTeam,
|
...prev.memberSpawnStatusesByTeam,
|
||||||
[teamName]: snapshot.statuses,
|
[teamName]: snapshot.statuses,
|
||||||
|
|
@ -2837,6 +3123,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||||
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
||||||
|
|
@ -2865,6 +3152,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchTeamDataDeduped(teamName);
|
const data = await fetchTeamDataDeduped(teamName);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
const ipcMs = performance.now() - startedAt;
|
||||||
// Stale check: user may have switched to another team during the async call
|
// Stale check: user may have switched to another team during the async call
|
||||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||||
|
|
@ -2993,6 +3283,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If provisioning is in progress for this team, stay in loading state;
|
// If provisioning is in progress for this team, stay in loading state;
|
||||||
// file watcher / progress callback will refresh once config is written.
|
// file watcher / progress callback will refresh once config is written.
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
|
|
@ -3041,7 +3334,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
inFlightRefreshTeamDataCalls.add(teamName);
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
|
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
const reusedInFlightRequest =
|
const reusedInFlightRequest =
|
||||||
|
|
@ -3058,6 +3352,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const data = opts?.withDedup
|
const data = opts?.withDedup
|
||||||
? await fetchTeamDataDeduped(teamName)
|
? await fetchTeamDataDeduped(teamName)
|
||||||
: await fetchTeamDataFresh(teamName);
|
: await fetchTeamDataFresh(teamName);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
const ipcMs = performance.now() - startedAt;
|
||||||
const projectedTeamData = previousData
|
const projectedTeamData = previousData
|
||||||
? {
|
? {
|
||||||
|
|
@ -3124,6 +3421,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
burstCount,
|
burstCount,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg =
|
const msg =
|
||||||
error instanceof IpcError
|
error instanceof IpcError
|
||||||
? error.message
|
? error.message
|
||||||
|
|
@ -3182,7 +3482,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
set({ selectedTeamError: msg });
|
set({ selectedTeamError: msg });
|
||||||
} finally {
|
} finally {
|
||||||
inFlightRefreshTeamDataCalls.delete(teamName);
|
endInFlightTeamDataRefresh(teamName, refreshToken);
|
||||||
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) {
|
||||||
void get().refreshTeamData(teamName);
|
void get().refreshTeamData(teamName);
|
||||||
}
|
}
|
||||||
|
|
@ -3202,10 +3502,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
||||||
if (existingOlderRequest) {
|
if (existingOlderRequest) {
|
||||||
|
const queuedEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
|
const queuedRequest: Promise<RefreshTeamMessagesHeadResult> = existingOlderRequest
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, queuedEpoch)) {
|
||||||
|
return {
|
||||||
|
feedChanged: false,
|
||||||
|
headChanged: false,
|
||||||
|
feedRevision: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) {
|
if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) {
|
||||||
queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName);
|
queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
feedChanged: false,
|
||||||
|
headChanged: false,
|
||||||
|
feedRevision: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return get().refreshTeamMessagesHead(teamName);
|
return get().refreshTeamMessagesHead(teamName);
|
||||||
})
|
})
|
||||||
|
|
@ -3218,7 +3532,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return queuedRequest;
|
return queuedRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async (): Promise<RefreshTeamMessagesHeadResult> => {
|
let request!: Promise<RefreshTeamMessagesHeadResult>;
|
||||||
|
request = (async (): Promise<RefreshTeamMessagesHeadResult> => {
|
||||||
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -3233,6 +3549,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const page = await unwrapIpc('team:getMessagesPage', () =>
|
const page = await unwrapIpc('team:getMessagesPage', () =>
|
||||||
api.teams.getMessagesPage(teamName, { limit: 50 })
|
api.teams.getMessagesPage(teamName, { limit: 50 })
|
||||||
);
|
);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return {
|
||||||
|
feedChanged: false,
|
||||||
|
headChanged: false,
|
||||||
|
feedRevision: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const previousEntry = getTeamMessagesCacheEntry(get(), teamName);
|
const previousEntry = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
const feedChanged =
|
const feedChanged =
|
||||||
|
|
@ -3282,6 +3605,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
feedRevision: page.feedRevision,
|
feedRevision: page.feedRevision,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return {
|
||||||
|
feedChanged: false,
|
||||||
|
headChanged: false,
|
||||||
|
feedRevision: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -3293,9 +3623,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}));
|
}));
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
inFlightTeamMessagesHeadRequests.delete(teamName);
|
if (inFlightTeamMessagesHeadRequests.get(teamName) === request) {
|
||||||
if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) {
|
inFlightTeamMessagesHeadRequests.delete(teamName);
|
||||||
void get().refreshTeamMessagesHead(teamName);
|
if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) {
|
||||||
|
void get().refreshTeamMessagesHead(teamName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -3305,6 +3637,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
loadOlderTeamMessages: async (teamName: string) => {
|
loadOlderTeamMessages: async (teamName: string) => {
|
||||||
|
const requestedEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName);
|
||||||
if (existingRequest) {
|
if (existingRequest) {
|
||||||
return existingRequest;
|
return existingRequest;
|
||||||
|
|
@ -3313,11 +3646,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName);
|
const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName);
|
||||||
if (existingHeadRequest) {
|
if (existingHeadRequest) {
|
||||||
await existingHeadRequest;
|
await existingHeadRequest;
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = getTeamMessagesCacheEntry(get(), teamName);
|
let entry = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
if (!entry.headHydrated) {
|
if (!entry.headHydrated) {
|
||||||
await get().refreshTeamMessagesHead(teamName);
|
await get().refreshTeamMessagesHead(teamName);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, requestedEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
entry = getTeamMessagesCacheEntry(get(), teamName);
|
entry = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3325,7 +3664,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async (): Promise<void> => {
|
let request!: Promise<void>;
|
||||||
|
request = (async (): Promise<void> => {
|
||||||
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -3344,6 +3685,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const current = getTeamMessagesCacheEntry(get(), teamName);
|
const current = getTeamMessagesCacheEntry(get(), teamName);
|
||||||
if (current.feedRevision !== baseFeedRevision) {
|
if (current.feedRevision !== baseFeedRevision) {
|
||||||
|
|
@ -3392,6 +3736,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
...state.teamMessagesByName,
|
...state.teamMessagesByName,
|
||||||
|
|
@ -3402,7 +3749,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} 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;
|
return existingRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async (): Promise<void> => {
|
let request!: Promise<void>;
|
||||||
|
request = (async (): Promise<void> => {
|
||||||
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
try {
|
try {
|
||||||
const meta = await unwrapIpc('team:getMemberActivityMeta', () =>
|
const meta = await unwrapIpc('team:getMemberActivityMeta', () =>
|
||||||
api.teams.getMemberActivityMeta(teamName)
|
api.teams.getMemberActivityMeta(teamName)
|
||||||
);
|
);
|
||||||
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision;
|
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 {
|
} finally {
|
||||||
inFlightTeamMemberActivityMetaRequests.delete(teamName);
|
if (inFlightTeamMemberActivityMetaRequests.get(teamName) === request) {
|
||||||
if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) {
|
inFlightTeamMemberActivityMetaRequests.delete(teamName);
|
||||||
void get().refreshMemberActivityMeta(teamName);
|
if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) {
|
||||||
|
void get().refreshMemberActivityMeta(teamName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -3470,10 +3831,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs = 10_000) => {
|
syncTeamPendingReplyRefresh: (
|
||||||
|
teamName: string,
|
||||||
|
sourceId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
delayMs = 10_000
|
||||||
|
) => {
|
||||||
clearPendingReplyRefreshTimer(teamName);
|
clearPendingReplyRefreshTimer(teamName);
|
||||||
setPendingReplyRefreshEnabled(teamName, enabled);
|
const shouldKeepRefreshActive = setPendingReplyRefreshEnabled(teamName, sourceId, enabled);
|
||||||
if (!enabled) {
|
if (!shouldKeepRefreshActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3770,42 +4136,26 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
deleteTeam: async (teamName: string) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||||
|
invalidateTeamLocalStateEpoch(teamName);
|
||||||
clearPendingReplyRefreshTimer(teamName);
|
clearPendingReplyRefreshTimer(teamName);
|
||||||
setPendingReplyRefreshEnabled(teamName, false);
|
clearPendingReplyRefreshWaits(teamName);
|
||||||
|
clearTeamScopedTransientState(teamName);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextCache = state.teamDataCacheByName[teamName]
|
const clearedState = collectTeamScopedStateRemovals(state, teamName);
|
||||||
? { ...state.teamDataCacheByName }
|
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
|
||||||
: 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];
|
|
||||||
}
|
|
||||||
if (state.selectedTeamName === teamName) {
|
if (state.selectedTeamName === teamName) {
|
||||||
return {
|
return {
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
|
...clearedState,
|
||||||
...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}),
|
...tombstones,
|
||||||
...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
|
...clearedState,
|
||||||
...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}),
|
...tombstones,
|
||||||
...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
|
|
@ -3814,32 +4164,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
restoreTeam: async (teamName: string) => {
|
restoreTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
|
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
|
||||||
|
invalidateTeamLocalStateEpoch(teamName);
|
||||||
clearPendingReplyRefreshTimer(teamName);
|
clearPendingReplyRefreshTimer(teamName);
|
||||||
setPendingReplyRefreshEnabled(teamName, false);
|
clearPendingReplyRefreshWaits(teamName);
|
||||||
|
clearTeamScopedTransientState(teamName);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const hasSnapshot = Boolean(state.teamDataCacheByName[teamName]);
|
const clearedState = collectTeamScopedStateRemovals(state, teamName);
|
||||||
const hasMessages = Boolean(state.teamMessagesByName[teamName]);
|
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
|
||||||
const hasMeta = Boolean(state.memberActivityMetaByTeam[teamName]);
|
if (Object.keys(clearedState).length === 0) {
|
||||||
if (!hasSnapshot && !hasMessages && !hasMeta) {
|
return tombstones;
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
const nextState: Partial<TeamSlice> = {};
|
return {
|
||||||
if (hasSnapshot) {
|
...clearedState,
|
||||||
const nextCache = { ...state.teamDataCacheByName };
|
...tombstones,
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
await get().fetchAllTasks();
|
await get().fetchAllTasks();
|
||||||
|
|
@ -3847,35 +4185,28 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
permanentlyDeleteTeam: async (teamName: string) => {
|
permanentlyDeleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
|
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
|
||||||
|
invalidateTeamLocalStateEpoch(teamName);
|
||||||
clearPendingReplyRefreshTimer(teamName);
|
clearPendingReplyRefreshTimer(teamName);
|
||||||
setPendingReplyRefreshEnabled(teamName, false);
|
clearPendingReplyRefreshWaits(teamName);
|
||||||
|
clearTeamScopedTransientState(teamName);
|
||||||
const state = get();
|
const state = get();
|
||||||
const nextCache = { ...state.teamDataCacheByName };
|
const clearedState = collectTeamScopedStateRemovals(state, teamName);
|
||||||
const nextMessages = { ...state.teamMessagesByName };
|
const tombstones = buildTeamScopedProgressTombstones(state, teamName, nowIso());
|
||||||
const nextMeta = { ...state.memberActivityMetaByTeam };
|
|
||||||
delete nextCache[teamName];
|
|
||||||
delete nextMessages[teamName];
|
|
||||||
delete nextMeta[teamName];
|
|
||||||
if (state.selectedTeamName === teamName) {
|
if (state.selectedTeamName === teamName) {
|
||||||
set({
|
set({
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
teamDataCacheByName: nextCache,
|
...clearedState,
|
||||||
teamMessagesByName: nextMessages,
|
...tombstones,
|
||||||
memberActivityMetaByTeam: nextMeta,
|
|
||||||
});
|
});
|
||||||
} else if (state.teamDataCacheByName[teamName]) {
|
} else if (Object.keys(clearedState).length > 0) {
|
||||||
set({
|
set({
|
||||||
teamDataCacheByName: nextCache,
|
...clearedState,
|
||||||
teamMessagesByName: nextMessages,
|
...tombstones,
|
||||||
memberActivityMetaByTeam: nextMeta,
|
|
||||||
});
|
|
||||||
} else if (state.teamMessagesByName[teamName] || state.memberActivityMetaByTeam[teamName]) {
|
|
||||||
set({
|
|
||||||
teamMessagesByName: nextMessages,
|
|
||||||
memberActivityMetaByTeam: nextMeta,
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
set(tombstones);
|
||||||
}
|
}
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
await get().fetchAllTasks();
|
await get().fetchAllTasks();
|
||||||
|
|
@ -3884,6 +4215,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
createTeam: async (request: TeamCreateRequest) => {
|
createTeam: async (request: TeamCreateRequest) => {
|
||||||
// Ensure provisioning progress subscription is active (defensive).
|
// Ensure provisioning progress subscription is active (defensive).
|
||||||
get().subscribeProvisioningProgress();
|
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.
|
// Establish a per-team floor so late events from a previous run can't override UI.
|
||||||
const floor = nowIso();
|
const floor = nowIso();
|
||||||
|
|
@ -3917,25 +4252,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
||||||
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
|
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
|
||||||
delete nextRuntimeRunIdByTeam[request.teamName];
|
delete nextRuntimeRunIdByTeam[request.teamName];
|
||||||
const nextIgnoredRunIds = Object.fromEntries(
|
|
||||||
Object.entries(state.ignoredProvisioningRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
|
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
|
||||||
? {
|
? {
|
||||||
...Object.fromEntries(
|
...state.ignoredRuntimeRunIds,
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[previousRuntimeRunId]: request.teamName,
|
[previousRuntimeRunId]: request.teamName,
|
||||||
}
|
}
|
||||||
: Object.fromEntries(
|
: state.ignoredRuntimeRunIds;
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName);
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
provisioningRuns: cleaned,
|
provisioningRuns: cleaned,
|
||||||
provisioningErrorByTeam: nextErrors,
|
provisioningErrorByTeam: nextErrors,
|
||||||
|
|
@ -3945,8 +4268,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
finishedVisibleByTeam: nextFinishedVisible,
|
finishedVisibleByTeam: nextFinishedVisible,
|
||||||
toolHistoryByTeam: nextToolHistory,
|
toolHistoryByTeam: nextToolHistory,
|
||||||
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
||||||
ignoredProvisioningRunIds: nextIgnoredRunIds,
|
ignoredProvisioningRunIds: state.ignoredProvisioningRunIds,
|
||||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||||
|
...visibleLoadingResets,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4038,11 +4362,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
...state.currentRuntimeRunIdByTeam,
|
...state.currentRuntimeRunIdByTeam,
|
||||||
[request.teamName]: response.runId,
|
[request.teamName]: response.runId,
|
||||||
},
|
},
|
||||||
ignoredRuntimeRunIds: Object.fromEntries(
|
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|
@ -4082,6 +4401,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
launchTeam: async (request: TeamLaunchRequest) => {
|
launchTeam: async (request: TeamLaunchRequest) => {
|
||||||
// Ensure provisioning progress subscription is active (defensive).
|
// Ensure provisioning progress subscription is active (defensive).
|
||||||
get().subscribeProvisioningProgress();
|
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.
|
// Establish a per-team floor so late events from a previous run can't override UI.
|
||||||
const floor = nowIso();
|
const floor = nowIso();
|
||||||
|
|
@ -4115,25 +4438,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
|
||||||
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
|
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
|
||||||
delete nextRuntimeRunIdByTeam[request.teamName];
|
delete nextRuntimeRunIdByTeam[request.teamName];
|
||||||
const nextIgnoredRunIds = Object.fromEntries(
|
|
||||||
Object.entries(state.ignoredProvisioningRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
|
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
|
||||||
? {
|
? {
|
||||||
...Object.fromEntries(
|
...state.ignoredRuntimeRunIds,
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[previousRuntimeRunId]: request.teamName,
|
[previousRuntimeRunId]: request.teamName,
|
||||||
}
|
}
|
||||||
: Object.fromEntries(
|
: state.ignoredRuntimeRunIds;
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
const visibleLoadingResets = collectTeamScopedVisibleLoadingResets(state, request.teamName);
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
provisioningRuns: cleaned,
|
provisioningRuns: cleaned,
|
||||||
provisioningErrorByTeam: nextErrors,
|
provisioningErrorByTeam: nextErrors,
|
||||||
|
|
@ -4143,8 +4454,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
finishedVisibleByTeam: nextFinishedVisible,
|
finishedVisibleByTeam: nextFinishedVisible,
|
||||||
toolHistoryByTeam: nextToolHistory,
|
toolHistoryByTeam: nextToolHistory,
|
||||||
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
|
||||||
ignoredProvisioningRunIds: nextIgnoredRunIds,
|
ignoredProvisioningRunIds: state.ignoredProvisioningRunIds,
|
||||||
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
|
||||||
|
...visibleLoadingResets,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4218,11 +4530,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
...state.currentRuntimeRunIdByTeam,
|
...state.currentRuntimeRunIdByTeam,
|
||||||
[request.teamName]: response.runId,
|
[request.teamName]: response.runId,
|
||||||
},
|
},
|
||||||
ignoredRuntimeRunIds: Object.fromEntries(
|
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== request.teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|
@ -4418,11 +4725,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
...state.currentRuntimeRunIdByTeam,
|
...state.currentRuntimeRunIdByTeam,
|
||||||
[progress.teamName]: progress.runId,
|
[progress.teamName]: progress.runId,
|
||||||
},
|
},
|
||||||
ignoredRuntimeRunIds: Object.fromEntries(
|
|
||||||
Object.entries(state.ignoredRuntimeRunIds).filter(
|
|
||||||
([, teamName]) => teamName !== progress.teamName
|
|
||||||
)
|
|
||||||
),
|
|
||||||
provisioningErrorByTeam: nextErrors,
|
provisioningErrorByTeam: nextErrors,
|
||||||
provisioningSnapshotByTeam: nextSnapshots,
|
provisioningSnapshotByTeam: nextSnapshots,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -809,17 +809,20 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
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');
|
const electron = await import('electron');
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
||||||
const handler = handlers.get(TEAM_GET_DATA)!;
|
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.success).toBe(true);
|
||||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
expect(result.data?.teamName).toBe('my-team');
|
||||||
expect(service.getTeamData).not.toHaveBeenCalled();
|
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
||||||
vi.mocked(console.error).mockClear();
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
|
|
@ -894,7 +897,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
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');
|
const electron = await import('electron');
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
@ -902,11 +905,14 @@ describe('ipc teams handlers', () => {
|
||||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||||
const result = (await handler({} as never, 'my-team', {
|
const result = (await handler({} as never, 'my-team', {
|
||||||
limit: 50,
|
limit: 50,
|
||||||
})) as { success: boolean; error?: string };
|
})) as { success: boolean; data?: { feedRevision: string } };
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(true);
|
||||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
expect(result.data?.feedRevision).toBe('rev-1');
|
||||||
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||||
|
cursor: undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
vi.mocked(console.error).mockClear();
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
|
|
@ -940,17 +946,20 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
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');
|
const electron = await import('electron');
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
||||||
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
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.success).toBe(true);
|
||||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
expect(result.data?.feedRevision).toBe('rev-1');
|
||||||
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||||
vi.mocked(console.error).mockClear();
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ function createMemberSpawnRun(params?: {
|
||||||
expectedMembers?: string[];
|
expectedMembers?: string[];
|
||||||
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
|
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
|
||||||
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
|
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember?: Map<string, Set<string>>;
|
|
||||||
}) {
|
}) {
|
||||||
const teamName = params?.teamName ?? 'member-spawn-team';
|
const teamName = params?.teamName ?? 'member-spawn-team';
|
||||||
const expectedMembers = params?.expectedMembers ?? ['alice'];
|
const expectedMembers = params?.expectedMembers ?? ['alice'];
|
||||||
|
|
@ -196,8 +195,6 @@ function createMemberSpawnRun(params?: {
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
memberSpawnLeadInboxCursorByMember:
|
memberSpawnLeadInboxCursorByMember:
|
||||||
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember:
|
|
||||||
params?.memberSpawnProcessedLeadInboxMessageIdsByMember ?? new Map(),
|
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
activeToolCalls: new Map(),
|
activeToolCalls: new Map(),
|
||||||
isLaunch: false,
|
isLaunch: false,
|
||||||
|
|
@ -986,9 +983,6 @@ describe('TeamProvisioningService', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
startedAt: '2026-04-16T09:00:00.000Z',
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
|
|
||||||
['alice', new Set(['msg-1', 'msg-2'])],
|
|
||||||
]),
|
|
||||||
memberSpawnLeadInboxCursorByMember: new Map([
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
[
|
[
|
||||||
'alice',
|
'alice',
|
||||||
|
|
@ -1053,9 +1047,6 @@ describe('TeamProvisioningService', () => {
|
||||||
timestamp: '2026-04-16T10:00:00.000Z',
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
messageId: 'msg-1',
|
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 () => {
|
it('ignores teammate lead inbox signals that predate the current run', async () => {
|
||||||
|
|
@ -1080,7 +1071,6 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
expect(applySignalSpy).not.toHaveBeenCalled();
|
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||||
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
|
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
|
||||||
expect(run.memberSpawnProcessedLeadInboxMessageIdsByMember.size).toBe(0);
|
|
||||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
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 latestHeartbeatAt = '2026-04-16T10:05:00.000Z';
|
||||||
const existingEntry = createMemberSpawnStatusEntry({
|
const existingEntry = createMemberSpawnStatusEntry({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -1101,9 +1091,6 @@ describe('TeamProvisioningService', () => {
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
startedAt: '2026-04-16T09:00:00.000Z',
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
|
|
||||||
['alice', new Set(['msg-3'])],
|
|
||||||
]),
|
|
||||||
memberSpawnLeadInboxCursorByMember: new Map([
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
[
|
[
|
||||||
'alice',
|
'alice',
|
||||||
|
|
@ -1139,9 +1126,6 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
expect(applySignalSpy).not.toHaveBeenCalled();
|
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||||
expect(
|
|
||||||
run.memberSpawnProcessedLeadInboxMessageIdsByMember.get('alice')?.has('msg-2b')
|
|
||||||
).toBe(true);
|
|
||||||
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||||
timestamp: latestHeartbeatAt,
|
timestamp: latestHeartbeatAt,
|
||||||
messageId: 'msg-3',
|
messageId: 'msg-3',
|
||||||
|
|
@ -1165,9 +1149,6 @@ describe('TeamProvisioningService', () => {
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
|
|
||||||
['alice', new Set(['msg-1'])],
|
|
||||||
]),
|
|
||||||
memberSpawnLeadInboxCursorByMember: new Map([
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
[
|
[
|
||||||
'alice',
|
'alice',
|
||||||
|
|
@ -1202,17 +1183,11 @@ describe('TeamProvisioningService', () => {
|
||||||
timestamp: '2026-04-16T10:01:00.000Z',
|
timestamp: '2026-04-16T10:01:00.000Z',
|
||||||
messageId: 'msg-2',
|
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 () => {
|
it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => {
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
startedAt: '2026-04-16T09:00:00.000Z',
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
memberSpawnProcessedLeadInboxMessageIdsByMember: new Map([
|
|
||||||
['alice', new Set(['msg-2'])],
|
|
||||||
]),
|
|
||||||
memberSpawnLeadInboxCursorByMember: new Map([
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
[
|
[
|
||||||
'alice',
|
'alice',
|
||||||
|
|
@ -1256,9 +1231,6 @@ describe('TeamProvisioningService', () => {
|
||||||
timestamp: '2026-04-16T10:00:00.000Z',
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
messageId: 'msg-3',
|
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', () => {
|
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 { 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 { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
|
|
||||||
import type { TeamProvisioningPrepareResult } from '@shared/types';
|
import type { TeamProvisioningPrepareResult } from '@shared/types';
|
||||||
|
|
@ -17,6 +20,39 @@ function createDeferred<T>(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('runProviderPrepareDiagnostics', () => {
|
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 () => {
|
it('returns a failed provider result immediately when runtime preflight fails', async () => {
|
||||||
const prepareProvisioning = vi.fn<
|
const prepareProvisioning = vi.fn<
|
||||||
(
|
(
|
||||||
|
|
@ -42,9 +78,8 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
expect(prepareProvisioning).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits per-model progress updates and keeps failures scoped to the affected model', async () => {
|
it('batches uncached model probes per provider and keeps failures scoped to the affected model', async () => {
|
||||||
const deferred54 = createDeferred<TeamProvisioningPrepareResult>();
|
const deferredBatch = createDeferred<TeamProvisioningPrepareResult>();
|
||||||
const deferred52 = createDeferred<TeamProvisioningPrepareResult>();
|
|
||||||
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
|
const progressUpdates: Array<{ details: string[]; completedCount: number; totalCount: number }> =
|
||||||
[];
|
[];
|
||||||
|
|
||||||
|
|
@ -62,10 +97,8 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
message: 'CLI is warmed up and ready to launch',
|
message: 'CLI is warmed up and ready to launch',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (selectedModels[0] === 'gpt-5.4') {
|
expect(selectedModels).toEqual(['gpt-5.4', 'gpt-5.2-codex']);
|
||||||
return deferred54.promise;
|
return deferredBatch.promise;
|
||||||
}
|
|
||||||
return deferred52.promise;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resultPromise = runProviderPrepareDiagnostics({
|
const resultPromise = runProviderPrepareDiagnostics({
|
||||||
|
|
@ -83,24 +116,13 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
details: ['5.4 - checking...', '5.2 Codex - checking...'],
|
details: ['5.4 - checking...', '5.2 Codex - checking...'],
|
||||||
});
|
});
|
||||||
|
|
||||||
deferred54.resolve({
|
deferredBatch.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({
|
|
||||||
ready: false,
|
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.",
|
"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;
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
|
@ -117,6 +139,7 @@ describe('runProviderPrepareDiagnostics', () => {
|
||||||
'5.2 Codex - unavailable - Not available with Codex ChatGPT subscription',
|
'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 () => {
|
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({
|
getMessagesPage.mockResolvedValue({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
|
@ -282,8 +282,8 @@ describe('MemberMessagesTab', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getMessagesPage).not.toHaveBeenCalled();
|
expect(getMessagesPage).not.toHaveBeenCalled();
|
||||||
expect(host.textContent).toContain('No activity with this member');
|
expect(host.textContent).toContain('No loaded activity for this member yet');
|
||||||
expect(host.textContent).not.toContain('Load older messages');
|
expect(host.textContent).toContain('Load older messages');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ describe('team change throttling', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => {
|
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({
|
useStore.setState({
|
||||||
paneLayout: {
|
paneLayout: {
|
||||||
focusedPaneId: 'p1',
|
focusedPaneId: 'p1',
|
||||||
|
|
@ -304,7 +304,7 @@ describe('team change throttling', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => {
|
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 refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
||||||
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue