perf(team): defer enrichments after first paint

This commit is contained in:
777genius 2026-05-04 14:47:46 +03:00
parent f57d15c68f
commit b1b2e696e5
15 changed files with 1428 additions and 139 deletions

View file

@ -202,6 +202,7 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamFastMode,
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
@ -227,6 +228,42 @@ const logger = createLogger('IPC:teams');
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value == null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function validateTeamGetDataOptions(
value: unknown
): { valid: true; value: TeamGetDataOptions | undefined } | { valid: false; error: string } {
if (value === undefined) {
return { valid: true, value: undefined };
}
if (!isPlainObject(value)) {
return { valid: false, error: 'options must be an object' };
}
const allowed = new Set(['includeMemberBranches']);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
return { valid: false, error: `Unknown getData option: ${key}` };
}
}
const includeMemberBranches = value.includeMemberBranches;
if (includeMemberBranches !== undefined && typeof includeMemberBranches !== 'boolean') {
return { valid: false, error: 'includeMemberBranches must be a boolean' };
}
return {
valid: true,
value: includeMemberBranches === false ? { includeMemberBranches: false } : undefined,
};
}
/**
* In-memory set of rate-limit message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
@ -944,17 +981,27 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
async function handleGetData(
_event: IpcMainInvokeEvent,
teamName: unknown
teamName: unknown,
rawOptions?: unknown
): Promise<IpcResult<TeamViewSnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
const optionsResult = validateTeamGetDataOptions(rawOptions);
if (!optionsResult.valid) {
return { success: false, error: optionsResult.error };
}
const tn = validated.value!;
const getDataOptions = optionsResult.value;
const startedAt = Date.now();
let data: TeamViewSnapshot;
let dataSource: 'worker' | 'main-fallback' | 'main-unavailable' = 'main-unavailable';
let workerAvailable = false;
const readFromMain = (): Promise<TeamViewSnapshot> =>
getDataOptions === undefined
? getTeamDataService().getTeamData(tn)
: getTeamDataService().getTeamData(tn, getDataOptions);
setCurrentMainOp('team:getData');
try {
// Prefer worker thread to keep main event loop responsive
@ -970,19 +1017,22 @@ async function handleGetData(
if (workerAvailable) {
try {
data = await worker.getTeamData(tn);
data =
getDataOptions === undefined
? await worker.getTeamData(tn)
: await worker.getTeamData(tn, getDataOptions);
dataSource = 'worker';
} catch (workerErr) {
logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
);
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
data = await readFromMain();
dataSource = 'main-fallback';
}
} else {
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
data = await readFromMain();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@ -1011,8 +1061,9 @@ async function handleGetData(
const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1500) {
const branchMode = getDataOptions?.includeMemberBranches === false ? 'skipped' : 'full';
logger.warn(
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable}`
`[teams:getData] slow team=${tn} ms=${getDataMs} source=${dataSource} workerAvailable=${workerAvailable} branchMode=${branchMode}`
);
}
const teamDataService = getTeamDataService();

View file

@ -86,6 +86,7 @@ import type {
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamGetDataOptions,
TeamMember,
TeamMemberActivityMeta,
TeamMemberSnapshot,
@ -1128,7 +1129,8 @@ export class TeamDataService {
TeamTaskReader.invalidateAllTasksCache();
}
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamViewSnapshot> {
const includeMemberBranches = options?.includeMemberBranches !== false;
const startedAt = Date.now();
const marks: Record<string, number> = {};
const mark = (label: string): void => {
@ -1376,8 +1378,11 @@ export class TeamDataService {
}
mark('runtimeAdvisories');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
// Enrich members with git branch when it differs from lead's branch.
// UI-first reads can skip this because the renderer hydrates branches through branch sync.
if (includeMemberBranches) {
await this.enrichMemberBranches(members, config);
}
mark('enrichBranches');
mark('syncComments');
@ -1392,6 +1397,7 @@ export class TeamDataService {
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
const branchMode = includeMemberBranches ? 'full' : 'skipped';
logger.warn(
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
@ -1412,7 +1418,7 @@ export class TeamDataService {
)}/enrichBranches=${msBetween(
'runtimeAdvisories',
'enrichBranches'
)}/processes=${msBetween('syncComments', 'processes')} ${counts}${
)}/processes=${msBetween('syncComments', 'processes')} branchMode=${branchMode} ${counts}${
warnings.length > 0 ? ` warnings=${warnings.join('|')}` : ''
}`
);

View file

@ -17,6 +17,7 @@ import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWo
import type {
MemberLogSummary,
MessagesPage,
TeamGetDataOptions,
TeamMemberActivityMeta,
TeamViewSnapshot,
} from '@shared/types';
@ -63,38 +64,59 @@ interface PendingEntry {
reject: (e: Error) => void;
}
function summarizeWorkerPayload(
payload: TeamDataWorkerRequest['payload']
): Record<string, unknown> {
if (!payload) {
return {};
}
if ('taskId' in payload) {
return {
teamName: payload.teamName,
taskId: payload.taskId,
owner: payload.options?.owner,
status: payload.options?.status,
intervals: Array.isArray(payload.options?.intervals)
? payload.options.intervals.length
: undefined,
since: payload.options?.since,
};
}
if ('options' in payload) {
return {
teamName: payload.teamName,
cursor:
typeof payload.options.cursor === 'string'
? payload.options.cursor.slice(0, 24)
: payload.options.cursor,
limit: payload.options.limit,
};
}
if ('teamName' in payload) {
return {
teamName: payload.teamName,
};
function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined {
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
}
function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `${teamName}\u0000branches:${normalizedOptions ? '0' : '1'}`;
}
function getTeamDataRequestPayload(
teamName: string,
options?: TeamGetDataOptions
): Extract<TeamDataWorkerRequest, { op: 'getTeamData' }>['payload'] {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return normalizedOptions ? { teamName, options: normalizedOptions } : { teamName };
}
function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record<string, unknown> {
switch (request.op) {
case 'warmup':
return {};
case 'getTeamData': {
const { teamName, options } = request.payload;
return {
teamName,
includeMemberBranches: options?.includeMemberBranches !== false,
};
}
case 'getMessagesPage': {
const { teamName, options } = request.payload;
return {
teamName,
cursor: typeof options.cursor === 'string' ? options.cursor.slice(0, 24) : options.cursor,
limit: options.limit,
};
}
case 'getMemberActivityMeta':
case 'invalidateTeamConfig':
case 'invalidateTeamMessageFeed':
return {
teamName: request.payload.teamName,
};
case 'findLogsForTask':
return {
teamName: request.payload.teamName,
taskId: request.payload.taskId,
owner: request.payload.options?.owner,
status: request.payload.options?.status,
intervals: Array.isArray(request.payload.options?.intervals)
? request.payload.options.intervals.length
: undefined,
since: request.payload.options?.since,
};
}
return {};
}
@ -169,6 +191,7 @@ export class TeamDataWorkerClient {
): Promise<unknown> {
const worker = this.ensureWorker();
const id = makeId();
const request = { id, op, payload } as TeamDataWorkerRequest;
const startedAt = Date.now();
const pendingAtStart = this.pending.size;
@ -177,7 +200,7 @@ export class TeamDataWorkerClient {
const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
logger.warn(
`worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
summarizeWorkerRequest(request)
)}`
);
this.failWorker(worker, timeoutError);
@ -192,7 +215,7 @@ export class TeamDataWorkerClient {
if (ms >= 1500) {
logger.warn(
`worker call slow op=${op} ms=${ms} workerTotalMs=${String(diag?.totalMs ?? 'unknown')} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
summarizeWorkerRequest(request)
)}`
);
}
@ -204,7 +227,7 @@ export class TeamDataWorkerClient {
if (ms >= 1500) {
logger.warn(
`worker call failed slow op=${op} ms=${ms} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
summarizeWorkerRequest(request)
)} error=${error.message}`
);
}
@ -212,7 +235,7 @@ export class TeamDataWorkerClient {
},
});
worker.postMessage({ id, op, payload } as TeamDataWorkerRequest);
worker.postMessage(request);
});
}
@ -237,36 +260,37 @@ export class TeamDataWorkerClient {
): void {
const worker = this.worker;
if (!worker) return;
const request = { id: makeId(), op, payload } as TeamDataWorkerRequest;
try {
worker.postMessage({ id: makeId(), op, payload } as TeamDataWorkerRequest);
worker.postMessage(request);
} catch (error) {
logger.debug(
`worker best-effort post failed op=${op} payload=${JSON.stringify(
summarizeWorkerPayload(payload)
summarizeWorkerRequest(request)
)} error=${error instanceof Error ? error.message : String(error)}`
);
}
}
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
async getTeamData(teamName: string, options?: TeamGetDataOptions): Promise<TeamViewSnapshot> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
const existing = this.getTeamDataInFlight.get(teamName);
const key = getTeamDataRequestKey(teamName, options);
const existing = this.getTeamDataInFlight.get(key);
if (existing) return existing;
const promise = (this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>).finally(
() => {
if (this.getTeamDataInFlight.get(teamName) === promise) {
this.getTeamDataInFlight.delete(teamName);
}
const payload = getTeamDataRequestPayload(teamName, options);
const promise = (this.call('getTeamData', payload) as Promise<TeamViewSnapshot>).finally(() => {
if (this.getTeamDataInFlight.get(key) === promise) {
this.getTeamDataInFlight.delete(key);
}
);
this.getTeamDataInFlight.set(teamName, promise);
});
this.getTeamDataInFlight.set(key, promise);
return promise;
}
invalidateTeamConfig(teamName: string): void {
if (!SAFE_NAME_RE.test(teamName)) return;
this.getTeamDataInFlight.delete(teamName);
this.clearTeamDataInFlightForTeam(teamName);
this.clearMessagesPageInFlightForTeam(teamName);
this.postBestEffort('invalidateTeamConfig', { teamName });
}
@ -286,6 +310,15 @@ export class TeamDataWorkerClient {
}
}
private clearTeamDataInFlightForTeam(teamName: string): void {
const prefix = `${teamName}\u0000`;
for (const key of this.getTeamDataInFlight.keys()) {
if (key.startsWith(prefix)) {
this.getTeamDataInFlight.delete(key);
}
}
}
async getMessagesPage(
teamName: string,
options: { cursor?: string | null; limit: number }

View file

@ -5,6 +5,7 @@
import type {
MemberLogSummary,
MessagesPage,
TeamGetDataOptions,
TeamMemberActivityMeta,
TeamViewSnapshot,
} from '@shared/types';
@ -13,6 +14,7 @@ import type {
export interface GetTeamDataPayload {
teamName: string;
options?: TeamGetDataOptions;
}
export interface GetMessagesPagePayload {

View file

@ -50,7 +50,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
break;
}
case 'getTeamData': {
const result = await teamDataService.getTeamData(msg.payload.teamName);
const result = await teamDataService.getTeamData(msg.payload.teamName, msg.payload.options);
respond({ id: msg.id, ok: true, result, diag: buildDiag() });
break;
}

View file

@ -307,6 +307,7 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
@ -844,8 +845,11 @@ const electronAPI: ElectronAPI = {
list: async () => {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
},
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
getData: async (teamName: string, options?: TeamGetDataOptions) => {
if (options === undefined) {
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
}
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName, options);
},
getTaskChangePresence: async (teamName: string) => {
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(

View file

@ -61,6 +61,7 @@ import type {
TeamClaudeLogsResponse,
TeamCreateRequest,
TeamCreateResponse,
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
@ -706,7 +707,10 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
getData: async (
_teamName: string,
_options?: TeamGetDataOptions
): Promise<TeamViewSnapshot> => {
throw new Error('Teams detail is not available in browser mode');
},
getTaskChangePresence: async (): Promise<

View file

@ -601,7 +601,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
e.stopPropagation();
void (async () => {
try {
const data = await api.teams.getData(teamName);
const data = await api.teams.getData(teamName, {
includeMemberBranches: false,
});
const existingNames = teams.map((t) => t.teamName);
const uniqueName = generateUniqueName(teamName, existingNames);
const members = (data.members ?? [])
@ -653,7 +655,9 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
e.stopPropagation();
if (!projectPath) return;
try {
const data = await api.teams.getData(teamName);
const data = await api.teams.getData(teamName, {
includeMemberBranches: false,
});
setLaunchDialogTeamName(teamName);
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);

View file

@ -53,6 +53,7 @@ import type {
TeamAgentRuntimeEntry,
TeamAgentRuntimeSnapshot,
TeamCreateRequest,
TeamGetDataOptions,
TeamLaunchRequest,
TeamMemberActivityMeta,
TeamMemberSnapshot,
@ -77,9 +78,19 @@ const TEAM_FETCH_TIMEOUT_MS = 30_000;
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
const POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS = 500;
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
const pendingFreshTeamDataRefreshes = new Set<string>();
const queuedFullTeamDataRefreshesAfterThin = new Set<string>();
interface PostPaintHandle {
rafId?: number;
timerId?: ReturnType<typeof setTimeout>;
fallbackTimerId?: ReturnType<typeof setTimeout>;
cancelled: boolean;
ran: boolean;
}
const postPaintTeamEnrichmentTimers = new Map<string, PostPaintHandle>();
const inFlightTeamMessagesHeadRequests = new Map<string, Promise<RefreshTeamMessagesHeadResult>>();
const inFlightTeamMessagesOlderRequests = new Map<string, Promise<void>>();
const queuedTeamMessagesHeadRefreshesAfterOlder = new Map<
@ -105,6 +116,55 @@ interface RefreshTeamDataOptions {
withDedup?: boolean;
}
type TeamDataSnapshotMode = 'full' | 'thin';
function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined {
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
}
function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean {
return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false;
}
function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode {
return shouldIncludeMemberBranches(options) ? 'full' : 'thin';
}
function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`;
}
function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`;
}
function getFullTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName);
}
function getThinTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName, { includeMemberBranches: false });
}
function hasFullTeamDataRequestForTeam(teamName: string): boolean {
return inFlightTeamDataRequests.has(getFullTeamDataRequestKey(teamName));
}
function hasThinTeamDataRequestForTeam(teamName: string): boolean {
return inFlightTeamDataRequests.has(getThinTeamDataRequestKey(teamName));
}
function clearTeamDataRequestsForTeam(teamName: string): void {
const prefix = `${teamName}\u0000`;
for (const key of inFlightTeamDataRequests.keys()) {
if (key.startsWith(prefix)) {
inFlightTeamDataRequests.delete(key);
}
}
}
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
type TeamGraphConfigMemberSeedInput = Pick<
@ -118,9 +178,10 @@ interface TeamGraphLayoutSessionState {
export function isTeamDataRefreshPending(teamName: string): boolean {
return (
inFlightTeamDataRequests.has(teamName) ||
hasFullTeamDataRequestForTeam(teamName) ||
(inFlightRefreshTeamDataCalls.get(teamName)?.size ?? 0) > 0 ||
pendingFreshTeamDataRefreshes.has(teamName)
pendingFreshTeamDataRefreshes.has(teamName) ||
queuedFullTeamDataRefreshesAfterThin.has(teamName)
);
}
@ -144,6 +205,11 @@ export function __resetTeamSliceModuleStateForTests(): void {
inFlightTeamDataRequests.clear();
inFlightRefreshTeamDataCalls.clear();
pendingFreshTeamDataRefreshes.clear();
queuedFullTeamDataRefreshesAfterThin.clear();
for (const teamName of postPaintTeamEnrichmentTimers.keys()) {
cancelPostPaintTeamEnrichments(teamName);
}
postPaintTeamEnrichmentTimers.clear();
inFlightTeamMessagesHeadRequests.clear();
inFlightTeamMessagesOlderRequests.clear();
queuedTeamMessagesHeadRefreshesAfterOlder.clear();
@ -184,9 +250,11 @@ function clearTeamScopedSelectorCaches(teamName: string): void {
}
function clearTeamScopedTransientState(teamName: string): void {
inFlightTeamDataRequests.delete(teamName);
clearTeamDataRequestsForTeam(teamName);
inFlightRefreshTeamDataCalls.delete(teamName);
pendingFreshTeamDataRefreshes.delete(teamName);
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
cancelPostPaintTeamEnrichments(teamName);
inFlightTeamMessagesHeadRequests.delete(teamName);
inFlightTeamMessagesOlderRequests.delete(teamName);
queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName);
@ -389,12 +457,171 @@ function endInFlightTeamDataRefresh(teamName: string, token: symbol): void {
}
}
function cancelPostPaintTeamEnrichments(teamName: string): void {
const handle = postPaintTeamEnrichmentTimers.get(teamName);
if (!handle) {
return;
}
handle.cancelled = true;
if (
handle.rafId !== undefined &&
typeof window !== 'undefined' &&
typeof window.cancelAnimationFrame === 'function'
) {
window.cancelAnimationFrame(handle.rafId);
}
if (handle.timerId !== undefined) {
clearTimeout(handle.timerId);
}
if (handle.fallbackTimerId !== undefined) {
clearTimeout(handle.fallbackTimerId);
}
postPaintTeamEnrichmentTimers.delete(teamName);
}
function scheduleAfterPaint(run: () => void): PostPaintHandle {
const handle: PostPaintHandle = {
cancelled: false,
ran: false,
};
const runOnce = (): void => {
if (handle.cancelled || handle.ran) {
return;
}
handle.ran = true;
if (
handle.rafId !== undefined &&
typeof window !== 'undefined' &&
typeof window.cancelAnimationFrame === 'function'
) {
window.cancelAnimationFrame(handle.rafId);
handle.rafId = undefined;
}
if (handle.timerId !== undefined) {
clearTimeout(handle.timerId);
handle.timerId = undefined;
}
if (handle.fallbackTimerId !== undefined) {
clearTimeout(handle.fallbackTimerId);
handle.fallbackTimerId = undefined;
}
run();
};
const scheduleTimer = (): void => {
handle.timerId = setTimeout(runOnce, 0);
};
handle.fallbackTimerId = setTimeout(runOnce, POST_PAINT_TEAM_ENRICHMENT_FALLBACK_MS);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
handle.rafId = window.requestAnimationFrame(() => {
handle.rafId = undefined;
scheduleTimer();
});
return handle;
}
scheduleTimer();
return handle;
}
function drainQueuedFullRefreshAfterThinSettles(teamName: string, get: () => TeamSlice): void {
if (!queuedFullTeamDataRefreshesAfterThin.delete(teamName)) {
return;
}
void get().refreshTeamData(teamName, { withDedup: true });
}
function isSelectedTeamLoadStillCurrent(
get: () => TeamSlice,
teamName: string,
requestNonce: number,
teamStateEpoch: number
): boolean {
const state = get();
return (
isTeamLocalStateEpochCurrent(teamName, teamStateEpoch) &&
state.selectedTeamName === teamName &&
state.selectedTeamLoadNonce === requestNonce &&
state.selectedTeamData?.teamName === teamName
);
}
function schedulePostPaintTeamEnrichments(params: {
teamName: string;
requestNonce: number;
teamStateEpoch: number;
get: () => TeamSlice;
}): void {
const { teamName, requestNonce, teamStateEpoch, get } = params;
cancelPostPaintTeamEnrichments(teamName);
const handle = scheduleAfterPaint(() => {
if (postPaintTeamEnrichmentTimers.get(teamName) !== handle) {
return;
}
postPaintTeamEnrichmentTimers.delete(teamName);
void (async () => {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
const state = get();
if (state.selectedTeamName !== teamName) {
drainQueuedFullRefreshAfterThinSettles(teamName, get);
return;
}
if (state.selectedTeamLoadNonce !== requestNonce) {
return;
}
if (state.selectedTeamData?.teamName !== teamName) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
if (queuedFullTeamDataRefreshesAfterThin.delete(teamName)) {
void get().refreshTeamData(teamName, { withDedup: true });
}
try {
const headResult = await get().refreshTeamMessagesHead(teamName);
if (!isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)) {
return;
}
if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) {
await get().refreshMemberActivityMeta(teamName);
}
} catch (error) {
logger.debug(
`post-paint team enrichments skipped team=${teamName} error=${
error instanceof Error ? error.message : String(error)
}`
);
}
})();
});
postPaintTeamEnrichmentTimers.set(teamName, handle);
}
export function __getTeamScopedTransientStateForTests(teamName: string): {
hasResolvedMembersSelector: boolean;
resolvedMemberSelectorCount: number;
hasMergedMessagesSelector: boolean;
memberMessagesSelectorCount: number;
hasPendingFreshTeamDataRefresh: boolean;
hasQueuedFullTeamDataRefreshAfterThin: boolean;
hasPostPaintTeamEnrichmentTimer: boolean;
hasQueuedHeadRefreshAfterOlder: boolean;
hasPendingFreshMessagesHeadRefresh: boolean;
hasPendingFreshMemberActivityMetaRefresh: boolean;
@ -425,6 +652,8 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName),
hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName),
hasQueuedHeadRefreshAfterOlder: queuedTeamMessagesHeadRefreshesAfterOlder.has(teamName),
hasPendingFreshMessagesHeadRefresh: pendingFreshTeamMessagesHeadRefreshes.has(teamName),
hasPendingFreshMemberActivityMetaRefresh:
@ -536,31 +765,48 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
});
}
function fetchTeamDataDeduped(teamName: string): Promise<TeamViewSnapshot> {
const existing = inFlightTeamDataRequests.get(teamName);
function fetchTeamDataDeduped(
teamName: string,
options?: TeamGetDataOptions
): Promise<TeamViewSnapshot> {
const normalizedOptions = normalizeTeamGetDataOptions(options);
const key = getTeamDataRequestKey(teamName, normalizedOptions);
const existing = inFlightTeamDataRequests.get(key);
if (existing) {
return existing;
}
const request = withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
unwrapIpc('team:getData', () =>
normalizedOptions === undefined
? api.teams.getData(teamName)
: api.teams.getData(teamName, normalizedOptions)
),
TEAM_GET_DATA_TIMEOUT_MS,
`team:getData(${teamName})`
getTeamDataRequestLabel(teamName, normalizedOptions)
).finally(() => {
if (inFlightTeamDataRequests.get(teamName) === request) {
inFlightTeamDataRequests.delete(teamName);
if (inFlightTeamDataRequests.get(key) === request) {
inFlightTeamDataRequests.delete(key);
}
});
inFlightTeamDataRequests.set(teamName, request);
inFlightTeamDataRequests.set(key, request);
return request;
}
function fetchTeamDataFresh(teamName: string): Promise<TeamViewSnapshot> {
function fetchTeamDataFresh(
teamName: string,
options?: TeamGetDataOptions
): Promise<TeamViewSnapshot> {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
unwrapIpc('team:getData', () =>
normalizedOptions === undefined
? api.teams.getData(teamName)
: api.teams.getData(teamName, normalizedOptions)
),
TEAM_GET_DATA_TIMEOUT_MS,
`team:getData(${teamName})`
getTeamDataRequestLabel(teamName, normalizedOptions)
);
}
@ -3412,6 +3658,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const requestNonce = get().selectedTeamLoadNonce + 1;
const previousData = selectTeamDataForName(get(), teamName);
cancelPostPaintTeamEnrichments(teamName);
// Repoint selection synchronously to the new team's cached snapshot when available.
// Never keep the previous team's snapshot attached to a newly selected team.
set({
@ -3426,12 +3674,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
try {
const data = await fetchTeamDataDeduped(teamName);
const data = await fetchTeamDataDeduped(teamName, {
includeMemberBranches: false,
});
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
// Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
const stateAfterLoad = get();
if (stateAfterLoad.selectedTeamName !== teamName) {
drainQueuedFullRefreshAfterThinSettles(teamName, get);
return;
}
if (stateAfterLoad.selectedTeamLoadNonce !== requestNonce) {
return;
}
// Eagerly patch teamByName with color/displayName from detailed data
@ -3479,80 +3735,105 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
};
});
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
if (invalidationState.cacheKeys.length > 0) {
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
}
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
const relatedTabs = allTabs.filter(
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
);
for (const tab of relatedTabs) {
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
if (tab.label !== nextLabel) {
get().updateTabLabel(tab.id, nextLabel);
try {
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
if (invalidationState.cacheKeys.length > 0) {
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
}
if (invalidationState.taskIds.length > 0) {
void api.review
.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds)
.catch(() => undefined);
}
}
const messagesHeadResult = await get().refreshTeamMessagesHead(teamName);
if (messagesHeadResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) {
await get().refreshMemberActivityMeta(teamName);
}
if (opts?.skipProjectAutoSelect) {
return;
}
// Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty.
const projectPath = data.config.projectPath;
if (projectPath) {
const state = get();
const normalizedTeamPath = normalizePath(projectPath);
// 1. Try flat projects list
const matchingProject = state.projects.find(
(p) => normalizePath(p.path) === normalizedTeamPath
// Sync tab label with the team's display name from config.
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
const relatedTabs = allTabs.filter(
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
);
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
state.selectProject(matchingProject.id);
} else if (!matchingProject) {
// 2. Try grouped view: search worktrees across all repository groups
for (const repo of state.repositoryGroups) {
const matchingWorktree = repo.worktrees.find(
(wt) => normalizePath(wt.path) === normalizedTeamPath
);
if (matchingWorktree) {
if (state.selectedWorktreeId !== matchingWorktree.id) {
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
void get().fetchSessionsInitial(matchingWorktree.id);
for (const tab of relatedTabs) {
const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName;
if (tab.label !== nextLabel) {
get().updateTabLabel(tab.id, nextLabel);
}
}
// Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty.
const projectPath = data.config.projectPath;
if (
!opts?.skipProjectAutoSelect &&
projectPath &&
isSelectedTeamLoadStillCurrent(get, teamName, requestNonce, teamStateEpoch)
) {
const state = get();
const normalizedTeamPath = normalizePath(projectPath);
// 1. Try flat projects list
const matchingProject = state.projects.find(
(p) => normalizePath(p.path) === normalizedTeamPath
);
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
state.selectProject(matchingProject.id);
} else if (!matchingProject) {
// 2. Try grouped view: search worktrees across all repository groups
for (const repo of state.repositoryGroups) {
const matchingWorktree = repo.worktrees.find(
(wt) => normalizePath(wt.path) === normalizedTeamPath
);
if (matchingWorktree) {
if (state.selectedWorktreeId !== matchingWorktree.id) {
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
void get().fetchSessionsInitial(matchingWorktree.id);
}
break;
}
break;
}
}
}
} catch (error) {
logger.debug(
`selectTeam(${teamName}) post-structural sync work failed: ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
schedulePostPaintTeamEnrichments({
teamName,
requestNonce,
teamStateEpoch,
get,
});
} catch (error) {
logger.debug(
`selectTeam(${teamName}) failed to schedule post-paint enrichments: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} catch (error) {
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
// If provisioning is in progress for this team, stay in loading state;
// file watcher / progress callback will refresh once config is written.
const currentState = get();
if (
currentState.selectedTeamName !== teamName ||
currentState.selectedTeamLoadNonce !== requestNonce
) {
if (currentState.selectedTeamName !== teamName) {
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
return;
}
if (currentState.selectedTeamLoadNonce !== requestNonce) {
return;
}
queuedFullTeamDataRefreshesAfterThin.delete(teamName);
const isProvisioning = isTeamProvisioningActive(currentState, teamName);
const msg = error instanceof Error ? error.message : String(error);
@ -3591,12 +3872,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
const fullKey = getFullTeamDataRequestKey(teamName);
const reusedInFlightRequest = opts?.withDedup === true && inFlightTeamDataRequests.has(fullKey);
const queuedBehindThinRequest =
opts?.withDedup === true && !reusedInFlightRequest && hasThinTeamDataRequestForTeam(teamName);
if (queuedBehindThinRequest) {
queuedFullTeamDataRefreshesAfterThin.add(teamName);
logger.debug(`refreshTeamData(${teamName}) queued behind thin team:getData`);
return;
}
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
const refreshToken = beginInFlightTeamDataRefresh(teamName);
// Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load).
const reusedInFlightRequest =
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
noteTeamRefreshBurst(teamName);
if (reusedInFlightRequest) {
pendingFreshTeamDataRefreshes.add(teamName);

View file

@ -73,6 +73,7 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamGetDataOptions,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
@ -440,7 +441,7 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamViewSnapshot>;
getData: (teamName: string, options?: TeamGetDataOptions) => Promise<TeamViewSnapshot>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;

View file

@ -875,6 +875,14 @@ export interface TeamMemberActivityMeta {
feedRevision: string;
}
export interface TeamGetDataOptions {
/**
* Default true.
* Set false only for UI-first reads where branch labels can arrive through branch sync.
*/
includeMemberBranches?: boolean;
}
export interface TeamViewSnapshot {
teamName: string;
config: TeamConfig;

View file

@ -1274,6 +1274,113 @@ describe('ipc teams handlers', () => {
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('forwards thin TEAM_GET_DATA options to the worker without changing full request shape', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team', {
includeMemberBranches: false,
})) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(true);
expect(result.data?.teamName).toBe('my-team');
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team', {
includeMemberBranches: false,
});
expect(service.getTeamData).not.toHaveBeenCalled();
});
it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team', {
includeMemberBranches: true,
})) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(true);
expect(mockTeamDataWorkerClient.getTeamData).toHaveBeenCalledWith('my-team');
});
it('forwards thin TEAM_GET_DATA options through packaged main-thread fallback', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team', {
includeMemberBranches: false,
})) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(true);
expect(service.getTeamData).toHaveBeenCalledWith('my-team', {
includeMemberBranches: false,
});
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('rejects malformed TEAM_GET_DATA options before dispatching to service or worker', async () => {
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team', {
includeMemberBranches: 'false',
})) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('includeMemberBranches');
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
expect(service.getTeamData).not.toHaveBeenCalled();
});
it.each([
['null options', null, 'options must be an object'],
['array options', [], 'options must be an object'],
['unknown option key', { includeMemberBranches: false, thin: true }, 'Unknown getData option'],
])(
'rejects malformed TEAM_GET_DATA %s before dispatching to service or worker',
async (_label, rawOptions, expectedError) => {
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team', rawOptions)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain(expectedError);
expect(mockTeamDataWorkerClient.getTeamData).not.toHaveBeenCalled();
expect(service.getTeamData).not.toHaveBeenCalled();
}
);
it('classifies draft teams before asking the team-data worker for a full snapshot', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-get-data-'));
setClaudeBasePathOverride(claudeRoot);

View file

@ -10,6 +10,7 @@ import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigR
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import { gitIdentityResolver } from '../../../../src/main/services/parsing/GitIdentityResolver';
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
import type {
@ -4594,6 +4595,86 @@ describe('TeamDataService', () => {
expect(harness.getConfig).not.toHaveBeenCalled();
});
it('skips member branch enrichment for thin UI team data snapshots', async () => {
const getBranchSpy = vi.spyOn(gitIdentityResolver, 'getBranch').mockResolvedValue('main');
const harness = createGetTeamDataHarness({
config: buildDefaultTeamConfig({
projectPath: '/repo',
members: [
{ name: 'team-lead', role: 'Lead', cwd: '/repo' },
{ name: 'alice', role: 'Developer', cwd: '/repo-alice' },
],
}),
resolveMembers: () => [
{ ...buildResolvedMember('team-lead'), cwd: '/repo' },
{ ...buildResolvedMember('alice'), cwd: '/repo-alice' },
],
});
const data = await harness.service.getTeamData('my-team', {
includeMemberBranches: false,
});
expect(getBranchSpy).not.toHaveBeenCalled();
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBeUndefined();
});
it('keeps member branch enrichment on by default for full UI team data snapshots', async () => {
const getBranchSpy = vi
.spyOn(gitIdentityResolver, 'getBranch')
.mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main'));
const harness = createGetTeamDataHarness({
config: buildDefaultTeamConfig({
projectPath: '/repo',
members: [
{ name: 'team-lead', role: 'Lead', cwd: '/repo' },
{ name: 'alice', role: 'Developer', cwd: '/repo-alice' },
],
}),
resolveMembers: () => [
{ ...buildResolvedMember('team-lead'), cwd: '/repo' },
{ ...buildResolvedMember('alice'), cwd: '/repo-alice' },
],
});
const data = await harness.service.getTeamData('my-team');
expect(getBranchSpy).toHaveBeenCalledWith('/repo');
expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice');
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice'
);
});
it('keeps member branch enrichment on for explicit full UI team data snapshots', async () => {
const getBranchSpy = vi
.spyOn(gitIdentityResolver, 'getBranch')
.mockImplementation(async (cwd) => (cwd === '/repo-alice' ? 'feature/alice' : 'main'));
const harness = createGetTeamDataHarness({
config: buildDefaultTeamConfig({
projectPath: '/repo',
members: [
{ name: 'team-lead', role: 'Lead', cwd: '/repo' },
{ name: 'alice', role: 'Developer', cwd: '/repo-alice' },
],
}),
resolveMembers: () => [
{ ...buildResolvedMember('team-lead'), cwd: '/repo' },
{ ...buildResolvedMember('alice'), cwd: '/repo-alice' },
],
});
const data = await harness.service.getTeamData('my-team', {
includeMemberBranches: true,
});
expect(getBranchSpy).toHaveBeenCalledWith('/repo');
expect(getBranchSpy).toHaveBeenCalledWith('/repo-alice');
expect(data.members.find((member) => member.name === 'alice')?.gitBranch).toBe(
'feature/alice'
);
});
it('uses snapshot config reads for UI message feed snapshots', async () => {
const harness = createGetTeamDataHarness();

View file

@ -93,6 +93,80 @@ describe('TeamDataWorkerClient', () => {
client.dispose();
});
it('does not deduplicate thin and full getTeamData calls together', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
await Promise.all([
client.getTeamData('my-team'),
client.getTeamData('my-team', { includeMemberBranches: false }),
]);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(2);
expect(hoisted.workers[0].messages[0]).toMatchObject({
op: 'getTeamData',
payload: { teamName: 'my-team' },
});
expect(hoisted.workers[0].messages[0]).not.toMatchObject({
payload: { options: expect.anything() },
});
expect(hoisted.workers[0].messages[1]).toMatchObject({
op: 'getTeamData',
payload: { teamName: 'my-team', options: { includeMemberBranches: false } },
});
client.dispose();
});
it('deduplicates explicit full getTeamData options with the default request', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
await Promise.all([
client.getTeamData('my-team'),
client.getTeamData('my-team', { includeMemberBranches: true }),
]);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(1);
expect(hoisted.workers[0].messages[0]).toMatchObject({
op: 'getTeamData',
payload: { teamName: 'my-team' },
});
expect(hoisted.workers[0].messages[0]).not.toMatchObject({
payload: { options: expect.anything() },
});
client.dispose();
});
it('deduplicates concurrent thin getTeamData calls for the same team', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
const [first, second] = await Promise.all([
client.getTeamData('my-team', { includeMemberBranches: false }),
client.getTeamData('my-team', { includeMemberBranches: false }),
]);
expect(first).toEqual(second);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(1);
expect(hoisted.workers[0].messages[0]).toMatchObject({
op: 'getTeamData',
payload: { teamName: 'my-team', options: { includeMemberBranches: false } },
});
client.dispose();
});
it('does not queue warmup behind an already running worker', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
@ -220,6 +294,70 @@ describe('TeamDataWorkerClient', () => {
client.dispose();
});
it('clears both thin and full getTeamData dedupe when invalidating team config', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
const client = new TeamDataWorkerClient();
const firstFull = client.getTeamData('my-team');
const firstThin = client.getTeamData('my-team', { includeMemberBranches: false });
client.invalidateTeamConfig('my-team');
const secondFull = client.getTeamData('my-team');
const secondThin = client.getTeamData('my-team', { includeMemberBranches: false });
await Promise.all([firstFull, firstThin, secondFull, secondThin]);
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([
'getTeamData',
'getTeamData',
'invalidateTeamConfig',
'getTeamData',
'getTeamData',
]);
const payloads = hoisted.workers[0].messages.map(
(message) => (message as { payload: unknown }).payload
);
expect(payloads).toEqual([
{ teamName: 'my-team' },
{ teamName: 'my-team', options: { includeMemberBranches: false } },
{ teamName: 'my-team' },
{ teamName: 'my-team' },
{ teamName: 'my-team', options: { includeMemberBranches: false } },
]);
client.dispose();
});
it('rejects and clears thin and full getTeamData requests on dispose', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'
);
hoisted.skipResponsesForOps.add('getTeamData');
const client = new TeamDataWorkerClient();
const full = client.getTeamData('my-team');
const thin = client.getTeamData('my-team', { includeMemberBranches: false });
expect(hoisted.workers).toHaveLength(1);
expect(hoisted.workers[0].messages).toHaveLength(2);
client.dispose();
await expect(full).rejects.toThrow('Client disposed');
await expect(thin).rejects.toThrow('Client disposed');
hoisted.skipResponsesForOps.delete('getTeamData');
await client.getTeamData('my-team');
expect(hoisted.workers).toHaveLength(2);
expect(hoisted.workers[1].messages).toHaveLength(1);
client.dispose();
});
it('does not spawn a worker only to send config invalidation', async () => {
const { TeamDataWorkerClient } = await import(
'../../../../src/main/services/team/TeamDataWorkerClient'

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import {
@ -42,6 +42,16 @@ const hoisted = vi.hoisted(() => ({
onProvisioningProgress: vi.fn(() => () => undefined),
}));
const originalWindowAnimationFrame =
typeof window === 'undefined'
? null
: {
hasRequest: Object.prototype.hasOwnProperty.call(window, 'requestAnimationFrame'),
hasCancel: Object.prototype.hasOwnProperty.call(window, 'cancelAnimationFrame'),
requestAnimationFrame: window.requestAnimationFrame,
cancelAnimationFrame: window.cancelAnimationFrame,
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
@ -179,6 +189,83 @@ function createDeferredPromise<T>() {
return { promise, resolve, reject };
}
function defineWindowAnimationFrame(
requestAnimationFrame: ((callback: FrameRequestCallback) => number) | undefined,
cancelAnimationFrame: ((handle: number) => void) | undefined
): void {
if (typeof window === 'undefined') {
return;
}
if (requestAnimationFrame === undefined) {
delete (window as Partial<Window>).requestAnimationFrame;
} else {
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: requestAnimationFrame,
});
}
if (cancelAnimationFrame === undefined) {
delete (window as Partial<Window>).cancelAnimationFrame;
} else {
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: cancelAnimationFrame,
});
}
}
function restoreWindowAnimationFrame(): void {
if (typeof window === 'undefined' || originalWindowAnimationFrame === null) {
return;
}
defineWindowAnimationFrame(
originalWindowAnimationFrame.hasRequest
? originalWindowAnimationFrame.requestAnimationFrame
: undefined,
originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined
);
}
function stubAnimationFrameWithTimer(): void {
defineWindowAnimationFrame(
(callback) => setTimeout(() => callback(Date.now()), 16) as unknown as number,
(handle) => clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
);
}
function stubAnimationFrameNeverFires(): void {
defineWindowAnimationFrame(
() => 1,
() => undefined
);
}
async function flushPostPaintTeamEnrichments(): Promise<void> {
await vi.advanceTimersByTimeAsync(16);
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
async function flushAsyncWork(): Promise<void> {
await flushMicrotasks();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
await flushMicrotasks();
}
function createRuntimeSnapshot(overrides: Record<string, unknown> = {}) {
return {
teamName: 'my-team',
@ -245,6 +332,11 @@ describe('teamSlice actions', () => {
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
});
afterEach(() => {
restoreWindowAnimationFrame();
vi.useRealTimers();
});
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
const store = createSliceStore();
const fetchTeams = vi.fn(async () => undefined);
@ -963,6 +1055,470 @@ describe('teamSlice actions', () => {
expect(store.getState().selectedTeamData).toBe(cachedBeta);
});
it('commits selectTeam thin snapshot before post-paint messages and activity meta refreshes', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const messagesRequest = createDeferredPromise<{
messages: Array<{
from: string;
text: string;
timestamp: string;
messageId: string;
source: 'inbox';
}>;
nextCursor: null;
hasMore: false;
feedRevision: string;
}>();
const metaRequest = createDeferredPromise<{
teamName: string;
computedAt: string;
feedRevision: string;
members: Record<string, never>;
}>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockImplementationOnce(() => messagesRequest.promise);
hoisted.getMemberActivityMeta.mockImplementationOnce(() => metaRequest.promise);
await store.getState().selectTeam('my-team');
expect(hoisted.getData).toHaveBeenCalledWith('my-team', {
includeMemberBranches: false,
});
expect(store.getState().selectedTeamLoading).toBe(false);
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(hoisted.getMessagesPage).not.toHaveBeenCalled();
expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled();
await flushPostPaintTeamEnrichments();
expect(hoisted.getMessagesPage).toHaveBeenCalledWith('my-team', { limit: 50 });
expect(hoisted.getMemberActivityMeta).not.toHaveBeenCalled();
messagesRequest.resolve({
messages: [],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-thin',
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
metaRequest.resolve({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
feedRevision: 'rev-thin',
members: {},
});
await Promise.resolve();
await Promise.resolve();
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('keeps selected team data visible when post-paint message refresh fails', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockRejectedValueOnce(new Error('message feed unavailable'));
await store.getState().selectTeam('my-team');
await flushPostPaintTeamEnrichments();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
expect(store.getState().teamMessagesByName['my-team']?.loadingHead).toBe(false);
});
it('queues a full team refresh behind an in-flight thin selectTeam snapshot', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const selectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
await store.getState().refreshTeamData('my-team', { withDedup: true });
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
await flushPostPaintTeamEnrichments();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('drains queued full team refresh through the post-paint fallback when rAF never fires', async () => {
vi.useFakeTimers();
stubAnimationFrameNeverFires();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(
createTeamSnapshot({
config: { name: 'Full Team After Fallback' },
})
);
const selectPromise = store.getState().selectTeam('my-team');
await Promise.resolve();
await store.getState().refreshTeamData('my-team', { withDedup: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
await vi.advanceTimersByTimeAsync(499);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await Promise.resolve();
await Promise.resolve();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(store.getState().selectedTeamData?.config.name).toBe('Full Team After Fallback');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
});
});
it('keeps selected team data visible when post-paint activity meta refresh fails', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
hoisted.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'alice',
text: 'Fresh message',
timestamp: '2026-03-12T10:00:00.000Z',
messageId: 'msg-fresh',
source: 'inbox',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-meta-fail',
});
hoisted.getMemberActivityMeta.mockRejectedValueOnce(new Error('meta unavailable'));
await store.getState().selectTeam('my-team');
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('does not share a forced full refresh request with an in-flight thin selectTeam request', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({ config: { name: 'Thin Team' } });
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[0]).toEqual([
'my-team',
{ includeMemberBranches: false },
]);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
thinRequest.resolve(thinSnapshot);
await selectPromise;
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
});
it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team Once' },
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await Promise.all([
store.getState().refreshTeamData('my-team', { withDedup: true }),
store.getState().refreshTeamData('my-team', { withDedup: true }),
]);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('drains queued full refresh when thin selectTeam becomes stale after switching teams', async () => {
const store = createSliceStore();
const alphaThin = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const alphaFull = createTeamSnapshot({
teamName: 'alpha-team',
config: { name: 'Alpha Full' },
});
hoisted.getData
.mockImplementationOnce(() => alphaThin.promise)
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }))
.mockResolvedValueOnce(alphaFull);
const alphaSelect = store.getState().selectTeam('alpha-team');
await flushMicrotasks();
await store.getState().refreshTeamData('alpha-team', { withDedup: true });
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
await store.getState().selectTeam('beta-team');
alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } }));
await alphaSelect;
await flushAsyncWork();
expect(hoisted.getData).toHaveBeenCalledTimes(3);
expect(hoisted.getData.mock.calls[2]).toEqual(['alpha-team']);
expect(store.getState().selectedTeamName).toBe('beta-team');
expect(store.getState().teamDataCacheByName['alpha-team']).toEqual(alphaFull);
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('clears queued full refresh when thin selectTeam fails structurally', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementationOnce(() => thinRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
});
thinRequest.reject(new Error('TEAM_DRAFT'));
await selectPromise;
expect(store.getState().selectedTeamError).toBe('TEAM_DRAFT');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('lets the newer same-team selectTeam drain queued full refresh after its own paint', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full After Newer Paint' },
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockResolvedValueOnce(fullSnapshot);
const firstSelect = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
const secondSelect = store
.getState()
.selectTeam('my-team', { allowReloadWhileProvisioning: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await Promise.all([firstSelect, secondSelect]);
expect(hoisted.getData).toHaveBeenCalledTimes(1);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
hasPostPaintTeamEnrichmentTimer: true,
});
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
});
});
it('does not run stale post-paint messages for a team after switching away', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
hoisted.getData
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } }))
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }));
await store.getState().selectTeam('alpha-team');
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
hasPostPaintTeamEnrichmentTimer: true,
});
await store.getState().selectTeam('beta-team');
await flushPostPaintTeamEnrichments();
await flushMicrotasks();
expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1);
expect(hoisted.getMessagesPage).toHaveBeenCalledWith('beta-team', { limit: 50 });
expect(hoisted.getMessagesPage).not.toHaveBeenCalledWith('alpha-team', { limit: 50 });
});
it('clears queued full refresh and post-paint timer when deleting a loaded team', async () => {
vi.useFakeTimers();
stubAnimationFrameWithTimer();
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
hoisted.getData.mockImplementationOnce(() => thinRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
await store.getState().refreshTeamData('my-team', { withDedup: true });
thinRequest.resolve(createTeamSnapshot({ config: { name: 'Thin Team' } }));
await selectPromise;
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: true,
hasPostPaintTeamEnrichmentTimer: true,
});
await store.getState().deleteTeam('my-team');
expect(__getTeamScopedTransientStateForTests('my-team')).toMatchObject({
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
});
await flushPostPaintTeamEnrichments();
expect(hoisted.getMessagesPage).not.toHaveBeenCalled();
expect(hoisted.getData).toHaveBeenCalledTimes(1);
});
it('keeps selected team data visible when post-structural sync work throws', async () => {
const store = createSliceStore();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Renamed Team' },
});
const updateTabLabel = vi.fn(() => {
throw new Error('tab label failed');
});
store.setState({
getAllPaneTabs: vi.fn(() => [
{ id: 'tab-1', type: 'team', teamName: 'my-team', label: 'Old Team' },
]),
updateTabLabel,
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
await store.getState().selectTeam('my-team');
expect(updateTabLabel).toHaveBeenCalledWith('tab-1', 'Renamed Team');
expect(store.getState().selectedTeamData).toEqual(thinSnapshot);
expect(store.getState().selectedTeamError).toBeNull();
});
it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => {
const store = createSliceStore();
const existingMessages = [
@ -1994,6 +2550,8 @@ describe('teamSlice actions', () => {
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
hasPendingFreshTeamDataRefresh: false,
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
hasQueuedHeadRefreshAfterOlder: false,
hasPendingFreshMessagesHeadRefresh: false,
hasPendingFreshMemberActivityMetaRefresh: false,
@ -2087,6 +2645,8 @@ describe('teamSlice actions', () => {
hasMergedMessagesSelector: false,
memberMessagesSelectorCount: 0,
hasPendingFreshTeamDataRefresh: false,
hasQueuedFullTeamDataRefreshAfterThin: false,
hasPostPaintTeamEnrichmentTimer: false,
hasQueuedHeadRefreshAfterOlder: false,
hasPendingFreshMessagesHeadRefresh: false,
hasPendingFreshMemberActivityMetaRefresh: false,